git-coco 0.39.0 → 0.40.1

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 +701 -173
  2. package/dist/index.js +701 -173
  3. package/package.json +3 -3
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.1";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -2152,11 +2152,21 @@ function formatAuthenticationError(error, logger) {
2152
2152
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2153
2153
  }
2154
2154
  /**
2155
- * Formats a generic error
2155
+ * Formats a generic error.
2156
+ *
2157
+ * The error message prints unconditionally (was previously gated behind
2158
+ * `--verbose`, which left users staring at a "Failed to execute command"
2159
+ * line with no actionable detail when something crashed). The full stack
2160
+ * trace stays under `logger.verbose` so plain output stays focused on the
2161
+ * one-line cause; users running into something they can't diagnose can opt
2162
+ * in with `--verbose` for the trace.
2156
2163
  */
2157
2164
  function formatGenericError(error, logger) {
2158
2165
  logger.log('\nFailed to execute command', { color: 'yellow' });
2159
- logger.verbose(`\nError: "${error.message}"`, { color: 'red' });
2166
+ logger.log(`\nError: ${error.message}`, { color: 'red' });
2167
+ if (error.stack) {
2168
+ logger.verbose(`\n${error.stack}`, { color: 'gray' });
2169
+ }
2160
2170
  }
2161
2171
  function commandExecutor(handler) {
2162
2172
  return async (argv) => {
@@ -14905,6 +14915,35 @@ function getLogInkWorkflowActions() {
14905
14915
  kind: 'normal',
14906
14916
  requiresConfirmation: false,
14907
14917
  },
14918
+ // Status surface group-level batch ops (#791 follow-up). Triggered
14919
+ // by Enter when the cursor is on a status group header
14920
+ // (Staged / Unstaged / Untracked). Empty `key` keeps them
14921
+ // palette-discoverable without registering a global hotkey — the
14922
+ // Enter-on-header path in inkInput is the canonical trigger.
14923
+ {
14924
+ id: 'unstage-all-staged',
14925
+ key: '',
14926
+ label: 'Unstage all staged files',
14927
+ description: 'Unstage every file currently in the staged group.',
14928
+ kind: 'normal',
14929
+ requiresConfirmation: false,
14930
+ },
14931
+ {
14932
+ id: 'stage-all-unstaged',
14933
+ key: '',
14934
+ label: 'Stage all unstaged files',
14935
+ description: 'Stage every modified-but-not-staged file.',
14936
+ kind: 'normal',
14937
+ requiresConfirmation: false,
14938
+ },
14939
+ {
14940
+ id: 'stage-all-untracked',
14941
+ key: '',
14942
+ label: 'Stage all untracked files',
14943
+ description: 'Add every untracked file to the index after confirmation.',
14944
+ kind: 'destructive',
14945
+ requiresConfirmation: true,
14946
+ },
14908
14947
  {
14909
14948
  id: 'delete-branch',
14910
14949
  key: 'D',
@@ -15922,6 +15961,88 @@ function extractDiffHunk(input) {
15922
15961
  return { patchText };
15923
15962
  }
15924
15963
 
15964
+ /**
15965
+ * Hardcoded per-entity action lists surfaced inside the right-hand
15966
+ * inspector panel. The inspector used to repeat the repo / branch /
15967
+ * status content the top header and left sidebar already show; we drop
15968
+ * that trailer in favor of an actionable cheat-sheet so the user knows
15969
+ * exactly which keystrokes apply to whatever they have under the cursor.
15970
+ *
15971
+ * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
15972
+ * - Most per-entity actions live in `inkInput.ts` as direct keystroke
15973
+ * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
15974
+ * globally-registered bindings, so the registry would be a partial
15975
+ * view at best.
15976
+ * - The bindings registry's `contexts` model (normal / search / focus
15977
+ * name) does not cleanly map to inspector entity types like "branch"
15978
+ * or "tag". Filtering it would mean replicating the same per-view
15979
+ * scoping logic the input dispatcher already encodes.
15980
+ * - New per-entity actions are added infrequently — the maintenance
15981
+ * cost of mirroring them here is low and keeps this file the single
15982
+ * source of truth for "what shows in the inspector".
15983
+ *
15984
+ * If you wire up a new per-entity keystroke in `inkInput.ts` — for
15985
+ * example a "create branch from this commit" or "create tag from this
15986
+ * commit" action — add the matching row to the relevant array below so
15987
+ * it shows up in the inspector automatically.
15988
+ */
15989
+ const HISTORY_COMMIT_ACTIONS = [
15990
+ { key: 'enter', label: 'Open diff' },
15991
+ { key: 'c', label: 'Cherry-pick' },
15992
+ { key: 'R', label: 'Revert', destructive: true },
15993
+ { key: 'Z', label: 'Reset to commit', destructive: true },
15994
+ { key: 'i', label: 'Interactive rebase', destructive: true },
15995
+ { key: 'y', label: 'Yank hash' },
15996
+ { key: 'Y', label: 'Yank short hash' },
15997
+ { key: 'O', label: 'Open in browser' },
15998
+ ];
15999
+ const BRANCH_ACTIONS = [
16000
+ { key: 'enter', label: 'Checkout' },
16001
+ { key: '+', label: 'New branch' },
16002
+ { key: 'R', label: 'Rename' },
16003
+ { key: 'u', label: 'Set upstream' },
16004
+ { key: 'D', label: 'Delete', destructive: true },
16005
+ { key: 'P', label: 'Push current' },
16006
+ { key: 'F', label: 'Fetch all' },
16007
+ { key: 'y', label: 'Yank name' },
16008
+ ];
16009
+ const TAG_ACTIONS = [
16010
+ { key: '+', label: 'New tag' },
16011
+ { key: 'P', label: 'Push tag' },
16012
+ { key: 'T', label: 'Delete', destructive: true },
16013
+ { key: 'R', label: 'Delete remote', destructive: true },
16014
+ { key: 'y', label: 'Yank name' },
16015
+ ];
16016
+ const STASH_ACTIONS = [
16017
+ { key: 'enter', label: 'Open diff' },
16018
+ { key: 'a', label: 'Apply' },
16019
+ { key: 'p', label: 'Pop' },
16020
+ { key: 'X', label: 'Drop', destructive: true },
16021
+ { key: 'y', label: 'Yank ref' },
16022
+ ];
16023
+ const WORKTREE_ACTIONS = [
16024
+ { key: 'W', label: 'Remove', destructive: true },
16025
+ { key: 'y', label: 'Yank path' },
16026
+ ];
16027
+ function getInspectorActions(context) {
16028
+ switch (context) {
16029
+ case 'history-commit':
16030
+ return HISTORY_COMMIT_ACTIONS;
16031
+ case 'branch':
16032
+ return BRANCH_ACTIONS;
16033
+ case 'tag':
16034
+ return TAG_ACTIONS;
16035
+ case 'stash':
16036
+ return STASH_ACTIONS;
16037
+ case 'worktree':
16038
+ return WORKTREE_ACTIONS;
16039
+ default: {
16040
+ const exhaustive = context;
16041
+ throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
16042
+ }
16043
+ }
16044
+ }
16045
+
15925
16046
  /**
15926
16047
  * Sort modes for the promoted views (P4.2).
15927
16048
  *
@@ -16128,6 +16249,7 @@ function withPushedView(state, value) {
16128
16249
  diffSource: value === 'diff' ? state.diffSource : undefined,
16129
16250
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16130
16251
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16252
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16131
16253
  pendingKey: undefined,
16132
16254
  };
16133
16255
  }
@@ -16150,6 +16272,7 @@ function withPoppedView(state) {
16150
16272
  diffSource: next === 'diff' ? state.diffSource : undefined,
16151
16273
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
16152
16274
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
16275
+ statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
16153
16276
  pendingKey: undefined,
16154
16277
  };
16155
16278
  }
@@ -16167,6 +16290,7 @@ function withReplacedView(state, value) {
16167
16290
  diffSource: value === 'diff' ? state.diffSource : undefined,
16168
16291
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16169
16292
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16293
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16170
16294
  pendingKey: undefined,
16171
16295
  };
16172
16296
  }
@@ -16298,9 +16422,12 @@ function createLogInkState(rows, options = {}) {
16298
16422
  focus: 'commits',
16299
16423
  sidebarTab: 'status',
16300
16424
  userSidebarTab: 'status',
16425
+ sidebarHeaderFocused: false,
16426
+ statusGroupHeaderFocused: false,
16301
16427
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16302
16428
  diffViewMode: 'unified',
16303
16429
  inspectorTab: 'inspector',
16430
+ inspectorActionIndex: 0,
16304
16431
  };
16305
16432
  }
16306
16433
  function getSelectedInkCommit(state) {
@@ -16341,12 +16468,21 @@ function applyLogInkAction(state, action) {
16341
16468
  return {
16342
16469
  ...state,
16343
16470
  focus: cycleValue(FOCUS_ORDER, state.focus, 1),
16471
+ // Reset header focus when leaving the sidebar so the next
16472
+ // re-entry starts on items rather than mid-flag.
16473
+ sidebarHeaderFocused: false,
16474
+ // Same idea for the status group header — Tab cycling away
16475
+ // from 'commits' should always land back on a real file when
16476
+ // the user returns.
16477
+ statusGroupHeaderFocused: false,
16344
16478
  pendingKey: undefined,
16345
16479
  };
16346
16480
  case 'focusPrevious':
16347
16481
  return {
16348
16482
  ...state,
16349
16483
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
16484
+ sidebarHeaderFocused: false,
16485
+ statusGroupHeaderFocused: false,
16350
16486
  pendingKey: undefined,
16351
16487
  };
16352
16488
  case 'move':
@@ -16411,6 +16547,9 @@ function applyLogInkAction(state, action) {
16411
16547
  selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
16412
16548
  selectedWorktreeHunkIndex: 0,
16413
16549
  worktreeDiffOffset: 0,
16550
+ // Cursor moved to a real file row — drop header focus so the
16551
+ // file Enter handler (open diff) is what fires next.
16552
+ statusGroupHeaderFocused: false,
16414
16553
  };
16415
16554
  }
16416
16555
  case 'moveBranch':
@@ -16429,10 +16568,40 @@ function applyLogInkAction(state, action) {
16429
16568
  selectedBranchIndex: 0,
16430
16569
  pendingKey: undefined,
16431
16570
  };
16571
+ case 'setSidebarHeaderFocused':
16572
+ return {
16573
+ ...state,
16574
+ sidebarHeaderFocused: action.value,
16575
+ pendingKey: undefined,
16576
+ };
16577
+ case 'setStatusGroupHeaderFocused':
16578
+ return {
16579
+ ...state,
16580
+ statusGroupHeaderFocused: action.value,
16581
+ pendingKey: undefined,
16582
+ };
16583
+ case 'jumpToStatusGroup':
16584
+ // Used by ←/→ on the status surface to land on the first file of
16585
+ // the previous / next non-empty group. Clears header focus so the
16586
+ // user is on a real file after the jump (matches the
16587
+ // sidebar pattern where ←/→ between tabs lands on items, not on
16588
+ // the next tab's header).
16589
+ return {
16590
+ ...state,
16591
+ selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
16592
+ selectedWorktreeHunkIndex: 0,
16593
+ worktreeDiffOffset: 0,
16594
+ statusGroupHeaderFocused: false,
16595
+ pendingKey: undefined,
16596
+ };
16432
16597
  case 'setInspectorTab':
16433
16598
  return {
16434
16599
  ...state,
16435
16600
  inspectorTab: action.value,
16601
+ // Reset the action cursor so a fresh tab visit always starts
16602
+ // on the first action, regardless of where the user left off
16603
+ // in a previous entity context.
16604
+ inspectorActionIndex: 0,
16436
16605
  pendingKey: undefined,
16437
16606
  };
16438
16607
  case 'cycleInspectorTab': {
@@ -16444,9 +16613,22 @@ function applyLogInkAction(state, action) {
16444
16613
  return {
16445
16614
  ...state,
16446
16615
  inspectorTab: next,
16616
+ inspectorActionIndex: 0,
16447
16617
  pendingKey: undefined,
16448
16618
  };
16449
16619
  }
16620
+ case 'moveInspectorAction':
16621
+ return {
16622
+ ...state,
16623
+ inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
16624
+ pendingKey: undefined,
16625
+ };
16626
+ case 'resetInspectorActionIndex':
16627
+ return {
16628
+ ...state,
16629
+ inspectorActionIndex: 0,
16630
+ pendingKey: undefined,
16631
+ };
16450
16632
  case 'moveTag':
16451
16633
  return {
16452
16634
  ...state,
@@ -16516,6 +16698,10 @@ function applyLogInkAction(state, action) {
16516
16698
  ...state,
16517
16699
  statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
16518
16700
  selectedWorktreeFileIndex: 0,
16701
+ // Group composition changed — header focus would be ambiguous
16702
+ // (cursor lands on file 0 which may belong to a different
16703
+ // group now). Reset to clear the indicator.
16704
+ statusGroupHeaderFocused: false,
16519
16705
  pendingKey: undefined,
16520
16706
  };
16521
16707
  }
@@ -16686,6 +16872,13 @@ function applyLogInkAction(state, action) {
16686
16872
  return {
16687
16873
  ...state,
16688
16874
  focus: action.value,
16875
+ // Reset sidebar header focus when leaving the sidebar so a
16876
+ // re-entry starts on items rather than mid-flag.
16877
+ sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
16878
+ // The status group header lives in the 'commits' focus on
16879
+ // the status view — clear when focus moves away so a
16880
+ // re-entry starts on a real file.
16881
+ statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
16689
16882
  pendingKey: undefined,
16690
16883
  };
16691
16884
  case 'setPendingKey':
@@ -16887,6 +17080,82 @@ function action(actionValue) {
16887
17080
  action: actionValue,
16888
17081
  };
16889
17082
  }
17083
+ /**
17084
+ * Resolve which inspector action context applies for the current
17085
+ * state. Today only history commits expose actions in the inspector
17086
+ * (the renderer hard-coded `'history-commit'`); future PRs can fan
17087
+ * this out to branch / tag / stash / worktree contexts as the
17088
+ * inspector gains entity-aware sections. Returns `undefined` when no
17089
+ * actions section should be shown (so the cursor model stays a
17090
+ * no-op).
17091
+ */
17092
+ function resolveInspectorActionContext(state) {
17093
+ if (state.activeView === 'history' && !state.pendingCommitFocused) {
17094
+ return 'history-commit';
17095
+ }
17096
+ return undefined;
17097
+ }
17098
+ function getInspectorActionsForState(state) {
17099
+ const ctx = resolveInspectorActionContext(state);
17100
+ return ctx ? getInspectorActions(ctx) : [];
17101
+ }
17102
+ /**
17103
+ * Synthesize the events that fire when the user presses Enter on a
17104
+ * cursored inspector action (#791 follow-up). Mirrors
17105
+ * `getLogInkPaletteExecuteEvents` — each action's `key` field
17106
+ * routes to the same dispatch the corresponding keystroke would
17107
+ * trigger from the history view's commit cursor. Per-key dispatch
17108
+ * (rather than recursively re-running the keystroke through
17109
+ * `getLogInkInputEvents`) avoids the gating problem: most history
17110
+ * keystroke handlers require `state.focus === 'commits'`, but the
17111
+ * inspector executor fires from `state.focus === 'detail'`.
17112
+ */
17113
+ function getInspectorActionExecuteEvents(inspectorAction, state) {
17114
+ const commit = state.filteredCommits[state.selectedIndex];
17115
+ const requireCommit = (fn) => {
17116
+ if (!commit) {
17117
+ return [action({ type: 'setStatus', value: 'No commit selected' })];
17118
+ }
17119
+ return fn(commit.hash, state.selectedIndex);
17120
+ };
17121
+ switch (inspectorAction.key) {
17122
+ case 'enter':
17123
+ return requireCommit((sha, commitIndex) => [
17124
+ action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
17125
+ ]);
17126
+ case 'c':
17127
+ return requireCommit(() => [
17128
+ action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
17129
+ ]);
17130
+ case 'R':
17131
+ return requireCommit(() => [
17132
+ action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
17133
+ ]);
17134
+ case 'Z':
17135
+ return requireCommit(() => [
17136
+ action({
17137
+ type: 'openInputPrompt',
17138
+ kind: 'reset-mode',
17139
+ label: 'Reset mode (soft / mixed / hard)',
17140
+ }),
17141
+ ]);
17142
+ case 'i':
17143
+ return requireCommit(() => [
17144
+ action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
17145
+ ]);
17146
+ case 'y':
17147
+ return requireCommit(() => [{ type: 'yankFromActiveView' }]);
17148
+ case 'Y':
17149
+ return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
17150
+ case 'O':
17151
+ return [{ type: 'runWorkflowAction', id: 'open-pr' }];
17152
+ default:
17153
+ return [action({
17154
+ type: 'setStatus',
17155
+ value: `Action ${inspectorAction.key} not yet wired`,
17156
+ })];
17157
+ }
17158
+ }
16890
17159
  /**
16891
17160
  * Build the events needed to apply the hunk under the diff cursor. The
16892
17161
  * runtime workflow handler expects payload format `<target>\n<patch>`
@@ -17698,11 +17967,74 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17698
17967
  if (key.rightArrow && state.focus === 'sidebar') {
17699
17968
  return [action({ type: 'nextSidebarTab' })];
17700
17969
  }
17970
+ // ←/→ on the inspector switch between the [Inspector] / [Actions]
17971
+ // tabs, mirroring the sidebar's left/right tab semantics. `[` and
17972
+ // `]` still work as keyboard alternatives, but the visible hint in
17973
+ // the inspector chrome shows ←/→ because the bracketed `[/]`
17974
+ // notation reads as "press the / key" — which is the global filter
17975
+ // trigger and was making users think the binding was busted.
17976
+ if (key.leftArrow && state.focus === 'detail') {
17977
+ return [action({ type: 'setInspectorTab', value: 'inspector' })];
17978
+ }
17979
+ if (key.rightArrow && state.focus === 'detail') {
17980
+ return [action({ type: 'setInspectorTab', value: 'actions' })];
17981
+ }
17982
+ // ←/→ on the status surface jump between the staged / unstaged /
17983
+ // untracked groups — the horizontal axis is "between groups", the
17984
+ // vertical axis (↑/↓ below) is "within the active group's files".
17985
+ // Lands on the first file of the target group (clears header
17986
+ // focus) so the user is always on a real file after a jump,
17987
+ // mirroring the sidebar's tab-switch landing behavior.
17988
+ if ((key.leftArrow || key.rightArrow) &&
17989
+ state.activeView === 'status' &&
17990
+ state.focus === 'commits' &&
17991
+ context.statusGroups &&
17992
+ context.statusGroups.length > 1) {
17993
+ const groups = context.statusGroups;
17994
+ const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
17995
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
17996
+ const fallback = currentIndex >= 0 ? currentIndex : 0;
17997
+ const delta = key.leftArrow ? -1 : 1;
17998
+ const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
17999
+ if (nextIndex !== fallback) {
18000
+ return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
18001
+ }
18002
+ return [];
18003
+ }
17701
18004
  if (key.upArrow || inputValue === 'k') {
18005
+ // Inspector Actions tab: ↑/↓ moves the cursor through the
18006
+ // executable action list. Wins over moveDetailFile so a
18007
+ // history-commit explore with both file list AND actions visible
18008
+ // navigates the actions when the user has [/]-toggled to the
18009
+ // actions tab. (#791 follow-up)
18010
+ if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
18011
+ return [action({
18012
+ type: 'moveInspectorAction',
18013
+ delta: -1,
18014
+ actionCount: context.inspectorActionCount,
18015
+ })];
18016
+ }
17702
18017
  if (state.focus === 'detail' && context.detailFileCount) {
17703
18018
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
17704
18019
  }
17705
18020
  if (state.activeView === 'status' && context.worktreeFileCount) {
18021
+ // Already on the group header — ↑ is a no-op (use ←/→ to switch
18022
+ // groups). Mirrors the sidebar's "header is the top of the
18023
+ // hierarchy" behavior.
18024
+ if (state.statusGroupHeaderFocused) {
18025
+ return [];
18026
+ }
18027
+ // Cursor at the first file of its group → promote to the group
18028
+ // header rather than crossing the boundary into the previous
18029
+ // group's last file. Keeps the cursor inside its current
18030
+ // container; ←/→ is the explicit way to move between groups.
18031
+ if (context.statusGroups && context.statusGroups.length > 0) {
18032
+ const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
18033
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
18034
+ if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
18035
+ return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
18036
+ }
18037
+ }
17706
18038
  return [action({
17707
18039
  type: 'moveWorktreeFile',
17708
18040
  delta: -1,
@@ -17727,6 +18059,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17727
18059
  previewLineCount: context.previewLineCount,
17728
18060
  })];
17729
18061
  }
18062
+ // Sidebar header focus: ↑ at item index 0 promotes the cursor
18063
+ // onto the active tab's header. Pressing ↑ again is a no-op
18064
+ // (use ←/→ to switch between tab headers, Enter to drill in).
18065
+ // Only triggers when the sidebar is focused on a content tab —
18066
+ // dedicated promoted views (`g b` etc.) keep the legacy clamp
18067
+ // behavior because they have no header to escape to.
18068
+ if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
18069
+ if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
18070
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18071
+ }
18072
+ if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
18073
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18074
+ }
18075
+ if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
18076
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18077
+ }
18078
+ if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
18079
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18080
+ }
18081
+ }
18082
+ // Already on the header — ↑ is a no-op (←/→ switches tabs).
18083
+ if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
18084
+ return [];
18085
+ }
17730
18086
  if (isBranchActionTarget(state) && context.branchCount) {
17731
18087
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
17732
18088
  }
@@ -17761,10 +18117,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17761
18117
  if (state.activeView === 'history' && state.pendingCommitFocused) {
17762
18118
  return [action({ type: 'unfocusPendingCommit' })];
17763
18119
  }
18120
+ if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
18121
+ return [action({
18122
+ type: 'moveInspectorAction',
18123
+ delta: 1,
18124
+ actionCount: context.inspectorActionCount,
18125
+ })];
18126
+ }
17764
18127
  if (state.focus === 'detail' && context.detailFileCount) {
17765
18128
  return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
17766
18129
  }
17767
18130
  if (state.activeView === 'status' && context.worktreeFileCount) {
18131
+ // Header focused → ↓ re-enters the group at the cursored file
18132
+ // (which is already the group's first file by construction).
18133
+ // Just clear the flag.
18134
+ if (state.statusGroupHeaderFocused) {
18135
+ return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
18136
+ }
17768
18137
  return [action({
17769
18138
  type: 'moveWorktreeFile',
17770
18139
  delta: 1,
@@ -17785,6 +18154,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17785
18154
  previewLineCount: context.previewLineCount,
17786
18155
  })];
17787
18156
  }
18157
+ // Sidebar header focused: ↓ re-enters the list at index 0.
18158
+ // Clears the header flag and snaps the per-entity selection to 0
18159
+ // (mirrors the existing default selection behavior on first
18160
+ // sidebar focus).
18161
+ if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
18162
+ return [action({ type: 'setSidebarHeaderFocused', value: false })];
18163
+ }
17788
18164
  if (isBranchActionTarget(state) && context.branchCount) {
17789
18165
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
17790
18166
  }
@@ -17878,6 +18254,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17878
18254
  ];
17879
18255
  }
17880
18256
  }
18257
+ // Inspector Actions tab: Enter on the cursored action fires its
18258
+ // associated event (cherry-pick / revert / yank / etc.). Wins over
18259
+ // the file-list Enter below when the user has [/]-toggled to the
18260
+ // actions tab. Routes through `getInspectorActionExecuteEvents` so
18261
+ // the per-action dispatch table stays the single source of truth
18262
+ // for what each action does. (#791 follow-up)
18263
+ if (key.return &&
18264
+ state.focus === 'detail' &&
18265
+ state.inspectorTab === 'actions') {
18266
+ const actions = getInspectorActionsForState(state);
18267
+ const cursored = actions[state.inspectorActionIndex];
18268
+ if (cursored) {
18269
+ return getInspectorActionExecuteEvents(cursored, state);
18270
+ }
18271
+ }
17881
18272
  // From the inspector / commit-diff detail panel, Enter opens (or refocuses)
17882
18273
  // the diff view scoped to the currently-selected commit and file. Lets the
17883
18274
  // user drive the explore flow entirely from the right panel: j/k picks a
@@ -17916,7 +18307,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17916
18307
  const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17917
18308
  const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17918
18309
  sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17919
- if (!hasInSidebarPrimaryAction) {
18310
+ // Three cases drill into the dedicated view:
18311
+ // 1. The cursor is on the tab header (user pressed ↑ at the
18312
+ // top of the list to escape the items — Enter explicitly
18313
+ // jumps to the dedicated view).
18314
+ // 2. The tab has no in-sidebar primary action defined (status,
18315
+ // tags, worktrees — drilling in is the canonical path).
18316
+ // 3. The tab has zero items (the dedicated view's empty state
18317
+ // tells the user what to do next).
18318
+ if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
17920
18319
  const tabToView = {
17921
18320
  status: 'status',
17922
18321
  branches: 'branches',
@@ -17936,6 +18335,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17936
18335
  // Fall through — per-entity Enter handler below claims the keystroke.
17937
18336
  }
17938
18337
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
18338
+ // Group header focused → fire the group's batch workflow action.
18339
+ // Routed through the workflow runner so the runtime owns the
18340
+ // git invocation + status messaging consistently with the
18341
+ // single-file `space` toggle. The `payload` carries the group's
18342
+ // state ('staged' / 'unstaged' / 'untracked') so the runtime can
18343
+ // resolve which files to act on without re-deriving group state.
18344
+ if (state.statusGroupHeaderFocused && context.statusGroups) {
18345
+ const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
18346
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
18347
+ if (currentGroup) {
18348
+ const workflowId = currentGroup.state === 'staged'
18349
+ ? 'unstage-all-staged'
18350
+ : currentGroup.state === 'unstaged'
18351
+ ? 'stage-all-unstaged'
18352
+ : 'stage-all-untracked';
18353
+ return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
18354
+ }
18355
+ }
17939
18356
  return [action({
17940
18357
  type: 'navigateOpenDiffForWorktreeFile',
17941
18358
  fileIndex: state.selectedWorktreeFileIndex,
@@ -20678,6 +21095,35 @@ function parseStashDiffFiles(lines) {
20678
21095
  }
20679
21096
  return files;
20680
21097
  }
21098
+ /**
21099
+ * Resolve which stash file *contains* a given line offset — the user's
21100
+ * cursor scrolls through a concatenated multi-file patch, and this is
21101
+ * what powers the "File N/M: <path>" panel header, the inline header
21102
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
21103
+ * dispatchers' "what file is the cursor on" lookup.
21104
+ *
21105
+ * Returns `undefined` when the file list is empty *or* the offset
21106
+ * lands before the very first file's `diff --git` header (e.g. when
21107
+ * `--stat` summary lines lead the patch). Callers fall through to a
21108
+ * "no file selected" state in that case.
21109
+ */
21110
+ function findStashFileForOffset(files, offset) {
21111
+ if (files.length === 0)
21112
+ return undefined;
21113
+ let current;
21114
+ for (const file of files) {
21115
+ if (file.startLine <= offset) {
21116
+ current = file;
21117
+ }
21118
+ else {
21119
+ break;
21120
+ }
21121
+ }
21122
+ // First file is the canonical fallback — even if the offset lands
21123
+ // before its header (rare), we want the cursor to be "in" something
21124
+ // so the user's actions have a target.
21125
+ return current ?? files[0];
21126
+ }
20681
21127
  const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
20682
21128
  function parseDiffGitHeader(line) {
20683
21129
  const match = line.match(DIFF_GIT_HEADER);
@@ -20732,6 +21178,25 @@ function revertFile(git, file) {
20732
21178
  }
20733
21179
  return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
20734
21180
  }
21181
+ /**
21182
+ * Group-level batch ops triggered by Enter on a status group header
21183
+ * (staged / unstaged / untracked). Pass the files belonging to that
21184
+ * group; the helpers run a single `git add` / `git restore --staged`
21185
+ * with all paths in one invocation rather than looping per-file —
21186
+ * faster + atomic from the user's point of view.
21187
+ */
21188
+ function stageAllFiles(git, files) {
21189
+ if (files.length === 0) {
21190
+ return Promise.resolve({ ok: false, message: 'No files to stage' });
21191
+ }
21192
+ return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
21193
+ }
21194
+ function unstageAllFiles(git, files) {
21195
+ if (files.length === 0) {
21196
+ return Promise.resolve({ ok: false, message: 'No files to unstage' });
21197
+ }
21198
+ return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
21199
+ }
20735
21200
 
20736
21201
  function fileState(indexStatus, worktreeStatus) {
20737
21202
  if (indexStatus === '?' && worktreeStatus === '?') {
@@ -20775,6 +21240,22 @@ function applyStatusFilterMask(files, mask) {
20775
21240
  }
20776
21241
  return files.filter((file) => mask[file.state]);
20777
21242
  }
21243
+ const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
21244
+ function groupWorktreeFiles(files) {
21245
+ const groups = [];
21246
+ let cursor = 0;
21247
+ for (const groupState of WORKTREE_GROUP_ORDER) {
21248
+ const groupFiles = files.filter((file) => file.state === groupState);
21249
+ if (groupFiles.length > 0) {
21250
+ groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
21251
+ cursor += groupFiles.length;
21252
+ }
21253
+ }
21254
+ return groups;
21255
+ }
21256
+ function flattenWorktreeGroups(groups) {
21257
+ return groups.flatMap((group) => group.files);
21258
+ }
20778
21259
 
20779
21260
  function hunkHeader(hunk) {
20780
21261
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -23124,88 +23605,6 @@ function formatPullRequestStateLine(pr) {
23124
23605
  return parts.join(' · ');
23125
23606
  }
23126
23607
 
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
23608
  function sectionLines(title, diff) {
23210
23609
  const lines = diff.split('\n').map((line) => line.trimEnd());
23211
23610
  return [
@@ -23761,7 +24160,16 @@ function LogInkApp(deps) {
23761
24160
  // count, selected-file resolution, and the rendered list all key off
23762
24161
  // it so toggles never desync the cursor from the rendered rows.
23763
24162
  const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
23764
- const selectedWorktreeFile = visibleWorktreeFiles[state.selectedWorktreeFileIndex];
24163
+ // Sectioned view of the visible files (#791 follow-up). Drives the
24164
+ // status surface's three-tier cursor model: ←/→ jumps between
24165
+ // groups, ↑ at index 0 promotes to the group header, Enter on the
24166
+ // header fires the group's batch action. The renderer also consumes
24167
+ // this so the visible file list stays in canonical group order
24168
+ // regardless of whatever order `git status --porcelain` happens to
24169
+ // emit.
24170
+ const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24171
+ const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24172
+ const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
23765
24173
  const dispatch = React.useCallback((action) => {
23766
24174
  setState((current) => applyLogInkAction(current, action));
23767
24175
  }, []);
@@ -24620,6 +25028,26 @@ function LogInkApp(deps) {
24620
25028
  return { ok: false, message: 'Comment body required' };
24621
25029
  return commentPullRequest(body);
24622
25030
  },
25031
+ // Status surface group-level batch ops (#791 follow-up). The
25032
+ // input handler dispatches these when the user presses Enter on a
25033
+ // group header. We re-derive the file list from the live
25034
+ // `context.worktree?.files` rather than trusting a snapshot —
25035
+ // the worktree may have changed since the keystroke fired (rare,
25036
+ // but the cost of re-filtering is negligible compared to the cost
25037
+ // of a stale add). The mask is honored too so a user who's
25038
+ // hidden a category never has it touched by accident.
25039
+ 'stage-all-unstaged': async () => {
25040
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
25041
+ return stageAllFiles(git, files);
25042
+ },
25043
+ 'unstage-all-staged': async () => {
25044
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
25045
+ return unstageAllFiles(git, files);
25046
+ },
25047
+ 'stage-all-untracked': async () => {
25048
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
25049
+ return stageAllFiles(git, files);
25050
+ },
24623
25051
  };
24624
25052
  const handler = handlers[id];
24625
25053
  if (!handler) {
@@ -24645,7 +25073,7 @@ function LogInkApp(deps) {
24645
25073
  }
24646
25074
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
24647
25075
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
24648
- state.tagSort]);
25076
+ state.statusFilterMask, state.tagSort]);
24649
25077
  // Resolve the active view's "yank target" (commit hash / branch /
24650
25078
  // tag / stash ref / file path) against the live filtered+sorted list,
24651
25079
  // copy it to the system clipboard, and surface the result on the
@@ -24700,7 +25128,7 @@ function LogInkApp(deps) {
24700
25128
  // Read from the mask-filtered list (#776) so the cursor and the
24701
25129
  // yanked path always match what's on screen — yanking a hidden
24702
25130
  // row is always a desync bug.
24703
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25131
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24704
25132
  if (path) {
24705
25133
  value = path;
24706
25134
  label = `path ${path}`;
@@ -24708,7 +25136,7 @@ function LogInkApp(deps) {
24708
25136
  }
24709
25137
  else if (view === 'diff') {
24710
25138
  if (state.diffSource === 'worktree') {
24711
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25139
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24712
25140
  if (path) {
24713
25141
  value = path;
24714
25142
  label = `path ${path}`;
@@ -24718,17 +25146,8 @@ function LogInkApp(deps) {
24718
25146
  // Walk back to the most recent file header at or before the
24719
25147
  // current preview offset — same logic the input-context block
24720
25148
  // 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
- }
25149
+ const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25150
+ if (current) {
24732
25151
  value = current.path;
24733
25152
  label = `path ${current.path}`;
24734
25153
  }
@@ -24782,7 +25201,7 @@ function LogInkApp(deps) {
24782
25201
  state.selectedTagIndex,
24783
25202
  state.selectedWorktreeFileIndex,
24784
25203
  state.tagSort,
24785
- visibleWorktreeFiles,
25204
+ visibleWorktreeFilesGrouped,
24786
25205
  ]);
24787
25206
  React.useEffect(() => {
24788
25207
  let active = true;
@@ -25022,28 +25441,14 @@ function LogInkApp(deps) {
25022
25441
  ? parseStashDiffFiles(stashDiffLines)
25023
25442
  : [];
25024
25443
  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
- })();
25444
+ const stashDiffSelectedPath = state.diffSource === 'stash'
25445
+ ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
25446
+ : undefined;
25042
25447
  getLogInkInputEvents(state, inputValue, key, {
25043
25448
  detailFileCount: detail?.files.length,
25044
25449
  previewLineCount: diffPreviewLineCount,
25045
25450
  worktreeDiffLineCount: worktreeDiff?.lines.length,
25046
- worktreeFileCount: visibleWorktreeFiles.length,
25451
+ worktreeFileCount: visibleWorktreeFilesGrouped.length,
25047
25452
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
25048
25453
  commitDiffHunkOffsets,
25049
25454
  branchCount: branchVisibleCount,
@@ -25053,7 +25458,13 @@ function LogInkApp(deps) {
25053
25458
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
25054
25459
  stashDiffSelectedPath,
25055
25460
  worktreeListCount: worktreeVisibleCount,
25056
- worktreeSelectedPath: visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path,
25461
+ worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
25462
+ statusGroups: visibleWorktreeGroups.map((group) => ({
25463
+ state: group.state,
25464
+ count: group.files.length,
25465
+ startIndex: group.startIndex,
25466
+ })),
25467
+ inspectorActionCount: getInspectorActionsForState(state).length,
25057
25468
  commitDiffSelectedPath: state.diffSource === 'commit'
25058
25469
  ? selectedDetailFile?.path
25059
25470
  : undefined,
@@ -25199,6 +25610,11 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25199
25610
  // Accordion layout — every tab's title is visible on its own line, but
25200
25611
  // only the active tab expands its content underneath. Switching tabs
25201
25612
  // (1-5 / [/]) collapses the previous and expands the next.
25613
+ // When sidebar focus has been promoted to the tab header (#806
25614
+ // follow-up), the active tab's title row gets selection styling
25615
+ // and the items below it render without their cursor highlight
25616
+ // (which now lives on the header).
25617
+ const headerFocused = focused && state.sidebarHeaderFocused;
25202
25618
  const tabBlocks = tabs.flatMap((tab, tabIndex) => {
25203
25619
  const isActive = tab === state.sidebarTab;
25204
25620
  const count = sidebarTabCount(tab, context);
@@ -25206,6 +25622,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25206
25622
  ? `${sidebarTabLabel(tab)} (${count})`
25207
25623
  : sidebarTabLabel(tab);
25208
25624
  const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
25625
+ const headerSelected = isActive && headerFocused;
25209
25626
  const blocks = [];
25210
25627
  if (tabIndex > 0) {
25211
25628
  blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
@@ -25214,6 +25631,12 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25214
25631
  key: `tab-header-${tab}`,
25215
25632
  bold: isActive,
25216
25633
  dimColor: !isActive,
25634
+ // Selection styling on the header itself when the cursor has
25635
+ // been promoted off the items list. inverse swaps fg/bg so the
25636
+ // highlight reads as "this is the cursor target" identically
25637
+ // to how items render when focused.
25638
+ backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
25639
+ inverse: headerSelected,
25217
25640
  }, headerText));
25218
25641
  if (isActive) {
25219
25642
  blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
@@ -25251,7 +25674,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
25251
25674
  // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
25252
25675
  // act on the cursored item without needing to drill into the
25253
25676
  // dedicated view (#791 follow-up — in-sidebar selection).
25254
- const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
25677
+ // Items render with the cursor highlight only when the sidebar is
25678
+ // focused on this tab AND the cursor is on items (not promoted to
25679
+ // the tab header). The header-focused branch up in `renderSidebar`
25680
+ // owns the highlight in that case.
25681
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
25255
25682
  if (tab === 'branches') {
25256
25683
  if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
25257
25684
  return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
@@ -25551,6 +25978,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
25551
25978
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
25552
25979
  }, truncate$1(label, 140));
25553
25980
  }
25981
+ function buildStatusSurfaceRows(groups) {
25982
+ const rows = [];
25983
+ for (const group of groups) {
25984
+ rows.push({ kind: 'header', group });
25985
+ group.files.forEach((file, offset) => {
25986
+ rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
25987
+ });
25988
+ }
25989
+ return rows;
25990
+ }
25554
25991
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25555
25992
  const { Box, Text } = components;
25556
25993
  const focused = state.focus === 'commits';
@@ -25560,26 +25997,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25560
25997
  // uses for j/k navigation. `visibleFiles` may be a strict subset of
25561
25998
  // worktree.files when the user has narrowed via 1/2/3.
25562
25999
  const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
26000
+ // Group + canonical-sort. The runtime + input handler agree on this
26001
+ // order so a `selectedWorktreeFileIndex` of N always points to the
26002
+ // same file across all three (renderer / input / workflow handlers).
26003
+ const visibleGroups = groupWorktreeFiles(visibleFiles);
26004
+ const surfaceRows = buildStatusSurfaceRows(visibleGroups);
25563
26005
  const listRows = Math.max(4, bodyRows - 5);
25564
26006
  const selectedIndex = state.selectedWorktreeFileIndex;
26007
+ const headerFocused = state.statusGroupHeaderFocused;
26008
+ // Resolve the cursor's row index in the flat (header-and-file) row
26009
+ // list. Used to window the visible slice around the cursor.
26010
+ const cursorRowIndex = (() => {
26011
+ if (!surfaceRows.length)
26012
+ return 0;
26013
+ const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
26014
+ if (!currentGroup)
26015
+ return 0;
26016
+ if (headerFocused) {
26017
+ const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
26018
+ return idx >= 0 ? idx : 0;
26019
+ }
26020
+ const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
26021
+ return idx >= 0 ? idx : 0;
26022
+ })();
25565
26023
  const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
25566
- const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
26024
+ const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
25567
26025
  const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
25568
- const fileRows = isLoading || !visibleFiles.length
26026
+ const renderedRows = isLoading || !surfaceRows.length
25569
26027
  ? []
25570
- : visibleFiles.slice(startIndex).slice(0, listRows).map((file, offset) => {
25571
- const index = startIndex + offset;
25572
- const isSelected = index === selectedIndex;
26028
+ : surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
26029
+ const rowIndex = windowStart + offset;
26030
+ if (row.kind === 'header') {
26031
+ const groupContainsCursor = selectedIndex >= row.group.startIndex &&
26032
+ selectedIndex < row.group.startIndex + row.group.files.length;
26033
+ const headerSelected = focused && headerFocused && groupContainsCursor;
26034
+ const arrow = theme.ascii ? '>' : '▾';
26035
+ const groupLabel = capitalizeGroupName(row.group.state);
26036
+ const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
26037
+ return h(Text, {
26038
+ key: `status-group-${row.group.state}-${rowIndex}`,
26039
+ bold: true,
26040
+ dimColor: !headerSelected && rowIndex > cursorRowIndex,
26041
+ backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
26042
+ inverse: headerSelected,
26043
+ }, truncate$1(text, 140));
26044
+ }
26045
+ const isSelected = !headerFocused && row.flatIndex === selectedIndex;
25573
26046
  const cursorPart = `${isSelected ? '>' : ' '} `;
25574
- const dotColor = getStageStatusDotColor(file.state, theme);
26047
+ const dotColor = getStageStatusDotColor(row.file.state, theme);
25575
26048
  const useDot = dotColor !== undefined;
25576
26049
  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));
26050
+ const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
26051
+ const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
25579
26052
  return h(Text, {
25580
- key: `status-row-${index}`,
25581
- dimColor: offset > 0,
25582
- }, cursorPart, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
26053
+ key: `status-file-${row.flatIndex}-${rowIndex}`,
26054
+ dimColor: !isSelected && rowIndex > cursorRowIndex,
26055
+ backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
26056
+ inverse: isSelected && focused,
26057
+ }, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
25583
26058
  });
25584
26059
  // When the mask narrows the list to nothing but the underlying repo
25585
26060
  // is non-clean, surface why the panel looks empty so the user can
@@ -25609,11 +26084,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25609
26084
  // never touch the filter.
25610
26085
  ...(isStatusFilterMaskActive(state.statusFilterMask)
25611
26086
  ? [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, {
26087
+ : []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
25613
26088
  key: `status-surface-fallback-${index}`,
25614
26089
  dimColor: index > 0,
25615
26090
  }, truncate$1(line, 140))));
25616
26091
  }
26092
+ function capitalizeGroupName(value) {
26093
+ return value.charAt(0).toUpperCase() + value.slice(1);
26094
+ }
25617
26095
  function isStatusFilterMaskActive(mask) {
25618
26096
  return !mask.staged || !mask.unstaged || !mask.untracked;
25619
26097
  }
@@ -26063,20 +26541,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
26063
26541
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
26064
26542
  const stashFiles = parseStashDiffFiles(lines);
26065
26543
  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
- })();
26544
+ const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
26080
26545
  const currentFileIndex = currentFile
26081
26546
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
26082
26547
  : -1;
@@ -26103,14 +26568,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
26103
26568
  const headerLines = splitRequestedButTooNarrow
26104
26569
  ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26105
26570
  : baseHeaderLines;
26571
+ // File header anchor map: absolute line index → owning stash file.
26572
+ // Lets the body-render pass restyle each `diff --git` row in O(1)
26573
+ // and decide which one is the *active* file (the one currently
26574
+ // containing `diffPreviewOffset`). The active header gets the
26575
+ // selection background to mark "the file the cursor is inside."
26576
+ const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
26577
+ const activeStartLine = currentFile?.startLine;
26106
26578
  const stashBodyNodes = stashDiffLoading || !lines.length
26107
26579
  ? []
26108
26580
  : splitActive
26109
26581
  ? 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)));
26582
+ : visibleLines.map((line, index) => {
26583
+ const absoluteIndex = state.diffPreviewOffset + index;
26584
+ const headerFile = stashFileByStartLine.get(absoluteIndex);
26585
+ if (headerFile) {
26586
+ // Replace the verbose `diff --git a/<path> b/<path>` text
26587
+ // with a compact `▾ <path>` marker — the path itself is
26588
+ // the meaningful identifier, not the a/b duplication. The
26589
+ // active file's header gets selection styling so the user
26590
+ // sees at a glance which file the cursor is inside.
26591
+ const isActive = absoluteIndex === activeStartLine;
26592
+ const arrow = theme.ascii ? '> ' : '▾ ';
26593
+ return h(Text, {
26594
+ key: `stash-diff-line-${absoluteIndex}`,
26595
+ bold: true,
26596
+ color: theme.noColor ? undefined : theme.colors.accent,
26597
+ backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
26598
+ inverse: isActive && focused,
26599
+ }, truncate$1(`${arrow}${headerFile.path}`, width - 4));
26600
+ }
26601
+ return h(Text, {
26602
+ key: `stash-diff-line-${absoluteIndex}`,
26603
+ ...diffLineProps(line, theme),
26604
+ }, truncate$1(line, width - 4));
26605
+ });
26114
26606
  return h(Box, {
26115
26607
  borderColor: focusBorderColor(theme, focused),
26116
26608
  borderStyle: theme.borderStyle,
@@ -26293,7 +26785,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26293
26785
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
26294
26786
  key: `detail-${index}`,
26295
26787
  dimColor: index > 1,
26296
- }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26788
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26789
+ cursorIndex: state.inspectorActionIndex,
26790
+ cursorActive: focused && state.inspectorTab === 'actions',
26791
+ }));
26297
26792
  }
26298
26793
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
26299
26794
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -26330,22 +26825,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26330
26825
  h(Text, { key: 'detail-spacer-3' }, ''),
26331
26826
  h(Text, { key: 'detail-files-title' }, 'Changed files:'),
26332
26827
  ];
26828
+ // Single-cursor invariant: the file list owns the cursor when the
26829
+ // inspector tab is active; the actions list owns it when the actions
26830
+ // tab is active. Pass `focused` only for the matching tab so users
26831
+ // never see two simultaneous selection highlights inside the panel.
26832
+ const fileListFocused = focused && state.inspectorTab === 'inspector';
26333
26833
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
26334
- const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
26335
- // Tabbed mode (#806 follow-up short terminals): render only the
26336
- // active inspector tab with a `[Inspector] Actions` header so the
26337
- // user knows what they're seeing and how to switch (`[/]` while
26338
- // focus is on the inspector). Tall terminals stack both sections
26339
- // as before.
26834
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
26835
+ // Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
26836
+ // tall-stacked mode so the user can always see which tab the cursor
26837
+ // owns and learn the `[/]` toggle. Without this on tall terminals,
26838
+ // the actions list looked like a static cheat-sheet there was no
26839
+ // visible signal that the cursor could move into it.
26840
+ //
26841
+ // Spacing between tab labels comes from the labels' own padding
26842
+ // (the active label is bracketed `[Inspector]` while the inactive
26843
+ // one is space-padded ` Inspector `, so adjacency reads cleanly).
26844
+ // Earlier revisions stuck a raw `' '` between the Text children to
26845
+ // pad them visually — that crashes Ink at first paint with
26846
+ // "Text string ' ' must be rendered inside <Text> component"
26847
+ // because Box only accepts component children, never bare strings.
26848
+ const activeTab = state.inspectorTab;
26849
+ const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26850
+ bold: activeTab === 'inspector',
26851
+ dimColor: activeTab !== 'inspector',
26852
+ }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
26853
+ bold: activeTab === 'actions',
26854
+ dimColor: activeTab !== 'actions',
26855
+ }, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
26856
+ ? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
26857
+ : []));
26858
+ // Tabbed mode (short terminals): render only the active tab's
26859
+ // content under the tab header.
26340
26860
  if (tabbed) {
26341
- const activeTab = state.inspectorTab;
26342
- const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26343
- bold: activeTab === 'inspector',
26344
- dimColor: activeTab !== 'inspector',
26345
- }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
26346
- bold: activeTab === 'actions',
26347
- dimColor: activeTab !== 'actions',
26348
- }, activeTab === 'actions' ? '[Actions]' : ' Actions '));
26349
26861
  return h(Box, {
26350
26862
  borderColor: focusBorderColor(theme, focused),
26351
26863
  borderStyle: theme.borderStyle,
@@ -26354,15 +26866,24 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26354
26866
  paddingX: 1,
26355
26867
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26356
26868
  ? [...headerNodes, ...fileListNodes]
26357
- : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26358
- }
26869
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26870
+ cursorIndex: state.inspectorActionIndex,
26871
+ cursorActive: focused,
26872
+ })));
26873
+ }
26874
+ // Tall mode: stack both sections so the user can read everything at
26875
+ // once, but show the tab header so the active section (and the
26876
+ // `[/]` switch affordance) is visible.
26359
26877
  return h(Box, {
26360
26878
  borderColor: focusBorderColor(theme, focused),
26361
26879
  borderStyle: theme.borderStyle,
26362
26880
  flexDirection: 'column',
26363
26881
  width,
26364
26882
  paddingX: 1,
26365
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26883
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26884
+ cursorIndex: state.inspectorActionIndex,
26885
+ cursorActive: focused && state.inspectorTab === 'actions',
26886
+ }));
26366
26887
  }
26367
26888
  /**
26368
26889
  * Render the trailing "Actions:" section that surfaces which keystrokes
@@ -26375,7 +26896,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26375
26896
  * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26376
26897
  * collides with the next row.
26377
26898
  */
26378
- function renderInspectorActionsSection(h, Text, context, width, theme) {
26899
+ function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
26379
26900
  const actions = getInspectorActions(context);
26380
26901
  if (!actions.length)
26381
26902
  return [];
@@ -26386,10 +26907,13 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
26386
26907
  const GAP = ' ';
26387
26908
  const DESTRUCTIVE_SUFFIX = ' [!]';
26388
26909
  const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26910
+ const cursorIndex = options.cursorIndex ?? 0;
26911
+ const cursorActive = options.cursorActive ?? false;
26389
26912
  const nodes = [
26390
26913
  h(Text, { key: 'actions-spacer' }, ''),
26391
- h(Text, { key: 'actions-title' }, 'Actions:'),
26914
+ h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
26392
26915
  ...actions.map((action, index) => {
26916
+ const isSelected = cursorActive && index === cursorIndex;
26393
26917
  const keyCell = action.key.padEnd(KEY_COLUMN);
26394
26918
  const label = truncate$1(action.label, labelBudget);
26395
26919
  const children = [
@@ -26407,7 +26931,11 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
26407
26931
  dimColor: false,
26408
26932
  }, DESTRUCTIVE_SUFFIX));
26409
26933
  }
26410
- return h(Text, { key: `actions-${index}` }, ...children);
26934
+ return h(Text, {
26935
+ key: `actions-${index}`,
26936
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
26937
+ inverse: isSelected,
26938
+ }, ...children);
26411
26939
  }),
26412
26940
  ];
26413
26941
  return nodes;