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
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
53
53
  /**
54
54
  * Current build version from package.json
55
55
  */
56
- const BUILD_VERSION = "0.39.0";
56
+ const BUILD_VERSION = "0.40.1";
57
57
 
58
58
  const isInteractive = (config) => {
59
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -2127,11 +2127,21 @@ function formatAuthenticationError(error, logger) {
2127
2127
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2128
2128
  }
2129
2129
  /**
2130
- * Formats a generic error
2130
+ * Formats a generic error.
2131
+ *
2132
+ * The error message prints unconditionally (was previously gated behind
2133
+ * `--verbose`, which left users staring at a "Failed to execute command"
2134
+ * line with no actionable detail when something crashed). The full stack
2135
+ * trace stays under `logger.verbose` so plain output stays focused on the
2136
+ * one-line cause; users running into something they can't diagnose can opt
2137
+ * in with `--verbose` for the trace.
2131
2138
  */
2132
2139
  function formatGenericError(error, logger) {
2133
2140
  logger.log('\nFailed to execute command', { color: 'yellow' });
2134
- logger.verbose(`\nError: "${error.message}"`, { color: 'red' });
2141
+ logger.log(`\nError: ${error.message}`, { color: 'red' });
2142
+ if (error.stack) {
2143
+ logger.verbose(`\n${error.stack}`, { color: 'gray' });
2144
+ }
2135
2145
  }
2136
2146
  function commandExecutor(handler) {
2137
2147
  return async (argv) => {
@@ -14880,6 +14890,35 @@ function getLogInkWorkflowActions() {
14880
14890
  kind: 'normal',
14881
14891
  requiresConfirmation: false,
14882
14892
  },
14893
+ // Status surface group-level batch ops (#791 follow-up). Triggered
14894
+ // by Enter when the cursor is on a status group header
14895
+ // (Staged / Unstaged / Untracked). Empty `key` keeps them
14896
+ // palette-discoverable without registering a global hotkey — the
14897
+ // Enter-on-header path in inkInput is the canonical trigger.
14898
+ {
14899
+ id: 'unstage-all-staged',
14900
+ key: '',
14901
+ label: 'Unstage all staged files',
14902
+ description: 'Unstage every file currently in the staged group.',
14903
+ kind: 'normal',
14904
+ requiresConfirmation: false,
14905
+ },
14906
+ {
14907
+ id: 'stage-all-unstaged',
14908
+ key: '',
14909
+ label: 'Stage all unstaged files',
14910
+ description: 'Stage every modified-but-not-staged file.',
14911
+ kind: 'normal',
14912
+ requiresConfirmation: false,
14913
+ },
14914
+ {
14915
+ id: 'stage-all-untracked',
14916
+ key: '',
14917
+ label: 'Stage all untracked files',
14918
+ description: 'Add every untracked file to the index after confirmation.',
14919
+ kind: 'destructive',
14920
+ requiresConfirmation: true,
14921
+ },
14883
14922
  {
14884
14923
  id: 'delete-branch',
14885
14924
  key: 'D',
@@ -15897,6 +15936,88 @@ function extractDiffHunk(input) {
15897
15936
  return { patchText };
15898
15937
  }
15899
15938
 
15939
+ /**
15940
+ * Hardcoded per-entity action lists surfaced inside the right-hand
15941
+ * inspector panel. The inspector used to repeat the repo / branch /
15942
+ * status content the top header and left sidebar already show; we drop
15943
+ * that trailer in favor of an actionable cheat-sheet so the user knows
15944
+ * exactly which keystrokes apply to whatever they have under the cursor.
15945
+ *
15946
+ * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
15947
+ * - Most per-entity actions live in `inkInput.ts` as direct keystroke
15948
+ * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
15949
+ * globally-registered bindings, so the registry would be a partial
15950
+ * view at best.
15951
+ * - The bindings registry's `contexts` model (normal / search / focus
15952
+ * name) does not cleanly map to inspector entity types like "branch"
15953
+ * or "tag". Filtering it would mean replicating the same per-view
15954
+ * scoping logic the input dispatcher already encodes.
15955
+ * - New per-entity actions are added infrequently — the maintenance
15956
+ * cost of mirroring them here is low and keeps this file the single
15957
+ * source of truth for "what shows in the inspector".
15958
+ *
15959
+ * If you wire up a new per-entity keystroke in `inkInput.ts` — for
15960
+ * example a "create branch from this commit" or "create tag from this
15961
+ * commit" action — add the matching row to the relevant array below so
15962
+ * it shows up in the inspector automatically.
15963
+ */
15964
+ const HISTORY_COMMIT_ACTIONS = [
15965
+ { key: 'enter', label: 'Open diff' },
15966
+ { key: 'c', label: 'Cherry-pick' },
15967
+ { key: 'R', label: 'Revert', destructive: true },
15968
+ { key: 'Z', label: 'Reset to commit', destructive: true },
15969
+ { key: 'i', label: 'Interactive rebase', destructive: true },
15970
+ { key: 'y', label: 'Yank hash' },
15971
+ { key: 'Y', label: 'Yank short hash' },
15972
+ { key: 'O', label: 'Open in browser' },
15973
+ ];
15974
+ const BRANCH_ACTIONS = [
15975
+ { key: 'enter', label: 'Checkout' },
15976
+ { key: '+', label: 'New branch' },
15977
+ { key: 'R', label: 'Rename' },
15978
+ { key: 'u', label: 'Set upstream' },
15979
+ { key: 'D', label: 'Delete', destructive: true },
15980
+ { key: 'P', label: 'Push current' },
15981
+ { key: 'F', label: 'Fetch all' },
15982
+ { key: 'y', label: 'Yank name' },
15983
+ ];
15984
+ const TAG_ACTIONS = [
15985
+ { key: '+', label: 'New tag' },
15986
+ { key: 'P', label: 'Push tag' },
15987
+ { key: 'T', label: 'Delete', destructive: true },
15988
+ { key: 'R', label: 'Delete remote', destructive: true },
15989
+ { key: 'y', label: 'Yank name' },
15990
+ ];
15991
+ const STASH_ACTIONS = [
15992
+ { key: 'enter', label: 'Open diff' },
15993
+ { key: 'a', label: 'Apply' },
15994
+ { key: 'p', label: 'Pop' },
15995
+ { key: 'X', label: 'Drop', destructive: true },
15996
+ { key: 'y', label: 'Yank ref' },
15997
+ ];
15998
+ const WORKTREE_ACTIONS = [
15999
+ { key: 'W', label: 'Remove', destructive: true },
16000
+ { key: 'y', label: 'Yank path' },
16001
+ ];
16002
+ function getInspectorActions(context) {
16003
+ switch (context) {
16004
+ case 'history-commit':
16005
+ return HISTORY_COMMIT_ACTIONS;
16006
+ case 'branch':
16007
+ return BRANCH_ACTIONS;
16008
+ case 'tag':
16009
+ return TAG_ACTIONS;
16010
+ case 'stash':
16011
+ return STASH_ACTIONS;
16012
+ case 'worktree':
16013
+ return WORKTREE_ACTIONS;
16014
+ default: {
16015
+ const exhaustive = context;
16016
+ throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
16017
+ }
16018
+ }
16019
+ }
16020
+
15900
16021
  /**
15901
16022
  * Sort modes for the promoted views (P4.2).
15902
16023
  *
@@ -16103,6 +16224,7 @@ function withPushedView(state, value) {
16103
16224
  diffSource: value === 'diff' ? state.diffSource : undefined,
16104
16225
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16105
16226
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16227
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16106
16228
  pendingKey: undefined,
16107
16229
  };
16108
16230
  }
@@ -16125,6 +16247,7 @@ function withPoppedView(state) {
16125
16247
  diffSource: next === 'diff' ? state.diffSource : undefined,
16126
16248
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
16127
16249
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
16250
+ statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
16128
16251
  pendingKey: undefined,
16129
16252
  };
16130
16253
  }
@@ -16142,6 +16265,7 @@ function withReplacedView(state, value) {
16142
16265
  diffSource: value === 'diff' ? state.diffSource : undefined,
16143
16266
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16144
16267
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16268
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16145
16269
  pendingKey: undefined,
16146
16270
  };
16147
16271
  }
@@ -16273,9 +16397,12 @@ function createLogInkState(rows, options = {}) {
16273
16397
  focus: 'commits',
16274
16398
  sidebarTab: 'status',
16275
16399
  userSidebarTab: 'status',
16400
+ sidebarHeaderFocused: false,
16401
+ statusGroupHeaderFocused: false,
16276
16402
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16277
16403
  diffViewMode: 'unified',
16278
16404
  inspectorTab: 'inspector',
16405
+ inspectorActionIndex: 0,
16279
16406
  };
16280
16407
  }
16281
16408
  function getSelectedInkCommit(state) {
@@ -16316,12 +16443,21 @@ function applyLogInkAction(state, action) {
16316
16443
  return {
16317
16444
  ...state,
16318
16445
  focus: cycleValue(FOCUS_ORDER, state.focus, 1),
16446
+ // Reset header focus when leaving the sidebar so the next
16447
+ // re-entry starts on items rather than mid-flag.
16448
+ sidebarHeaderFocused: false,
16449
+ // Same idea for the status group header — Tab cycling away
16450
+ // from 'commits' should always land back on a real file when
16451
+ // the user returns.
16452
+ statusGroupHeaderFocused: false,
16319
16453
  pendingKey: undefined,
16320
16454
  };
16321
16455
  case 'focusPrevious':
16322
16456
  return {
16323
16457
  ...state,
16324
16458
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
16459
+ sidebarHeaderFocused: false,
16460
+ statusGroupHeaderFocused: false,
16325
16461
  pendingKey: undefined,
16326
16462
  };
16327
16463
  case 'move':
@@ -16386,6 +16522,9 @@ function applyLogInkAction(state, action) {
16386
16522
  selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
16387
16523
  selectedWorktreeHunkIndex: 0,
16388
16524
  worktreeDiffOffset: 0,
16525
+ // Cursor moved to a real file row — drop header focus so the
16526
+ // file Enter handler (open diff) is what fires next.
16527
+ statusGroupHeaderFocused: false,
16389
16528
  };
16390
16529
  }
16391
16530
  case 'moveBranch':
@@ -16404,10 +16543,40 @@ function applyLogInkAction(state, action) {
16404
16543
  selectedBranchIndex: 0,
16405
16544
  pendingKey: undefined,
16406
16545
  };
16546
+ case 'setSidebarHeaderFocused':
16547
+ return {
16548
+ ...state,
16549
+ sidebarHeaderFocused: action.value,
16550
+ pendingKey: undefined,
16551
+ };
16552
+ case 'setStatusGroupHeaderFocused':
16553
+ return {
16554
+ ...state,
16555
+ statusGroupHeaderFocused: action.value,
16556
+ pendingKey: undefined,
16557
+ };
16558
+ case 'jumpToStatusGroup':
16559
+ // Used by ←/→ on the status surface to land on the first file of
16560
+ // the previous / next non-empty group. Clears header focus so the
16561
+ // user is on a real file after the jump (matches the
16562
+ // sidebar pattern where ←/→ between tabs lands on items, not on
16563
+ // the next tab's header).
16564
+ return {
16565
+ ...state,
16566
+ selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
16567
+ selectedWorktreeHunkIndex: 0,
16568
+ worktreeDiffOffset: 0,
16569
+ statusGroupHeaderFocused: false,
16570
+ pendingKey: undefined,
16571
+ };
16407
16572
  case 'setInspectorTab':
16408
16573
  return {
16409
16574
  ...state,
16410
16575
  inspectorTab: action.value,
16576
+ // Reset the action cursor so a fresh tab visit always starts
16577
+ // on the first action, regardless of where the user left off
16578
+ // in a previous entity context.
16579
+ inspectorActionIndex: 0,
16411
16580
  pendingKey: undefined,
16412
16581
  };
16413
16582
  case 'cycleInspectorTab': {
@@ -16419,9 +16588,22 @@ function applyLogInkAction(state, action) {
16419
16588
  return {
16420
16589
  ...state,
16421
16590
  inspectorTab: next,
16591
+ inspectorActionIndex: 0,
16422
16592
  pendingKey: undefined,
16423
16593
  };
16424
16594
  }
16595
+ case 'moveInspectorAction':
16596
+ return {
16597
+ ...state,
16598
+ inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
16599
+ pendingKey: undefined,
16600
+ };
16601
+ case 'resetInspectorActionIndex':
16602
+ return {
16603
+ ...state,
16604
+ inspectorActionIndex: 0,
16605
+ pendingKey: undefined,
16606
+ };
16425
16607
  case 'moveTag':
16426
16608
  return {
16427
16609
  ...state,
@@ -16491,6 +16673,10 @@ function applyLogInkAction(state, action) {
16491
16673
  ...state,
16492
16674
  statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
16493
16675
  selectedWorktreeFileIndex: 0,
16676
+ // Group composition changed — header focus would be ambiguous
16677
+ // (cursor lands on file 0 which may belong to a different
16678
+ // group now). Reset to clear the indicator.
16679
+ statusGroupHeaderFocused: false,
16494
16680
  pendingKey: undefined,
16495
16681
  };
16496
16682
  }
@@ -16661,6 +16847,13 @@ function applyLogInkAction(state, action) {
16661
16847
  return {
16662
16848
  ...state,
16663
16849
  focus: action.value,
16850
+ // Reset sidebar header focus when leaving the sidebar so a
16851
+ // re-entry starts on items rather than mid-flag.
16852
+ sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
16853
+ // The status group header lives in the 'commits' focus on
16854
+ // the status view — clear when focus moves away so a
16855
+ // re-entry starts on a real file.
16856
+ statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
16664
16857
  pendingKey: undefined,
16665
16858
  };
16666
16859
  case 'setPendingKey':
@@ -16862,6 +17055,82 @@ function action(actionValue) {
16862
17055
  action: actionValue,
16863
17056
  };
16864
17057
  }
17058
+ /**
17059
+ * Resolve which inspector action context applies for the current
17060
+ * state. Today only history commits expose actions in the inspector
17061
+ * (the renderer hard-coded `'history-commit'`); future PRs can fan
17062
+ * this out to branch / tag / stash / worktree contexts as the
17063
+ * inspector gains entity-aware sections. Returns `undefined` when no
17064
+ * actions section should be shown (so the cursor model stays a
17065
+ * no-op).
17066
+ */
17067
+ function resolveInspectorActionContext(state) {
17068
+ if (state.activeView === 'history' && !state.pendingCommitFocused) {
17069
+ return 'history-commit';
17070
+ }
17071
+ return undefined;
17072
+ }
17073
+ function getInspectorActionsForState(state) {
17074
+ const ctx = resolveInspectorActionContext(state);
17075
+ return ctx ? getInspectorActions(ctx) : [];
17076
+ }
17077
+ /**
17078
+ * Synthesize the events that fire when the user presses Enter on a
17079
+ * cursored inspector action (#791 follow-up). Mirrors
17080
+ * `getLogInkPaletteExecuteEvents` — each action's `key` field
17081
+ * routes to the same dispatch the corresponding keystroke would
17082
+ * trigger from the history view's commit cursor. Per-key dispatch
17083
+ * (rather than recursively re-running the keystroke through
17084
+ * `getLogInkInputEvents`) avoids the gating problem: most history
17085
+ * keystroke handlers require `state.focus === 'commits'`, but the
17086
+ * inspector executor fires from `state.focus === 'detail'`.
17087
+ */
17088
+ function getInspectorActionExecuteEvents(inspectorAction, state) {
17089
+ const commit = state.filteredCommits[state.selectedIndex];
17090
+ const requireCommit = (fn) => {
17091
+ if (!commit) {
17092
+ return [action({ type: 'setStatus', value: 'No commit selected' })];
17093
+ }
17094
+ return fn(commit.hash, state.selectedIndex);
17095
+ };
17096
+ switch (inspectorAction.key) {
17097
+ case 'enter':
17098
+ return requireCommit((sha, commitIndex) => [
17099
+ action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
17100
+ ]);
17101
+ case 'c':
17102
+ return requireCommit(() => [
17103
+ action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
17104
+ ]);
17105
+ case 'R':
17106
+ return requireCommit(() => [
17107
+ action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
17108
+ ]);
17109
+ case 'Z':
17110
+ return requireCommit(() => [
17111
+ action({
17112
+ type: 'openInputPrompt',
17113
+ kind: 'reset-mode',
17114
+ label: 'Reset mode (soft / mixed / hard)',
17115
+ }),
17116
+ ]);
17117
+ case 'i':
17118
+ return requireCommit(() => [
17119
+ action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
17120
+ ]);
17121
+ case 'y':
17122
+ return requireCommit(() => [{ type: 'yankFromActiveView' }]);
17123
+ case 'Y':
17124
+ return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
17125
+ case 'O':
17126
+ return [{ type: 'runWorkflowAction', id: 'open-pr' }];
17127
+ default:
17128
+ return [action({
17129
+ type: 'setStatus',
17130
+ value: `Action ${inspectorAction.key} not yet wired`,
17131
+ })];
17132
+ }
17133
+ }
16865
17134
  /**
16866
17135
  * Build the events needed to apply the hunk under the diff cursor. The
16867
17136
  * runtime workflow handler expects payload format `<target>\n<patch>`
@@ -17673,11 +17942,74 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17673
17942
  if (key.rightArrow && state.focus === 'sidebar') {
17674
17943
  return [action({ type: 'nextSidebarTab' })];
17675
17944
  }
17945
+ // ←/→ on the inspector switch between the [Inspector] / [Actions]
17946
+ // tabs, mirroring the sidebar's left/right tab semantics. `[` and
17947
+ // `]` still work as keyboard alternatives, but the visible hint in
17948
+ // the inspector chrome shows ←/→ because the bracketed `[/]`
17949
+ // notation reads as "press the / key" — which is the global filter
17950
+ // trigger and was making users think the binding was busted.
17951
+ if (key.leftArrow && state.focus === 'detail') {
17952
+ return [action({ type: 'setInspectorTab', value: 'inspector' })];
17953
+ }
17954
+ if (key.rightArrow && state.focus === 'detail') {
17955
+ return [action({ type: 'setInspectorTab', value: 'actions' })];
17956
+ }
17957
+ // ←/→ on the status surface jump between the staged / unstaged /
17958
+ // untracked groups — the horizontal axis is "between groups", the
17959
+ // vertical axis (↑/↓ below) is "within the active group's files".
17960
+ // Lands on the first file of the target group (clears header
17961
+ // focus) so the user is always on a real file after a jump,
17962
+ // mirroring the sidebar's tab-switch landing behavior.
17963
+ if ((key.leftArrow || key.rightArrow) &&
17964
+ state.activeView === 'status' &&
17965
+ state.focus === 'commits' &&
17966
+ context.statusGroups &&
17967
+ context.statusGroups.length > 1) {
17968
+ const groups = context.statusGroups;
17969
+ const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
17970
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
17971
+ const fallback = currentIndex >= 0 ? currentIndex : 0;
17972
+ const delta = key.leftArrow ? -1 : 1;
17973
+ const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
17974
+ if (nextIndex !== fallback) {
17975
+ return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
17976
+ }
17977
+ return [];
17978
+ }
17676
17979
  if (key.upArrow || inputValue === 'k') {
17980
+ // Inspector Actions tab: ↑/↓ moves the cursor through the
17981
+ // executable action list. Wins over moveDetailFile so a
17982
+ // history-commit explore with both file list AND actions visible
17983
+ // navigates the actions when the user has [/]-toggled to the
17984
+ // actions tab. (#791 follow-up)
17985
+ if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
17986
+ return [action({
17987
+ type: 'moveInspectorAction',
17988
+ delta: -1,
17989
+ actionCount: context.inspectorActionCount,
17990
+ })];
17991
+ }
17677
17992
  if (state.focus === 'detail' && context.detailFileCount) {
17678
17993
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
17679
17994
  }
17680
17995
  if (state.activeView === 'status' && context.worktreeFileCount) {
17996
+ // Already on the group header — ↑ is a no-op (use ←/→ to switch
17997
+ // groups). Mirrors the sidebar's "header is the top of the
17998
+ // hierarchy" behavior.
17999
+ if (state.statusGroupHeaderFocused) {
18000
+ return [];
18001
+ }
18002
+ // Cursor at the first file of its group → promote to the group
18003
+ // header rather than crossing the boundary into the previous
18004
+ // group's last file. Keeps the cursor inside its current
18005
+ // container; ←/→ is the explicit way to move between groups.
18006
+ if (context.statusGroups && context.statusGroups.length > 0) {
18007
+ const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
18008
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
18009
+ if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
18010
+ return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
18011
+ }
18012
+ }
17681
18013
  return [action({
17682
18014
  type: 'moveWorktreeFile',
17683
18015
  delta: -1,
@@ -17702,6 +18034,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17702
18034
  previewLineCount: context.previewLineCount,
17703
18035
  })];
17704
18036
  }
18037
+ // Sidebar header focus: ↑ at item index 0 promotes the cursor
18038
+ // onto the active tab's header. Pressing ↑ again is a no-op
18039
+ // (use ←/→ to switch between tab headers, Enter to drill in).
18040
+ // Only triggers when the sidebar is focused on a content tab —
18041
+ // dedicated promoted views (`g b` etc.) keep the legacy clamp
18042
+ // behavior because they have no header to escape to.
18043
+ if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
18044
+ if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
18045
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18046
+ }
18047
+ if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
18048
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18049
+ }
18050
+ if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
18051
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18052
+ }
18053
+ if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
18054
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18055
+ }
18056
+ }
18057
+ // Already on the header — ↑ is a no-op (←/→ switches tabs).
18058
+ if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
18059
+ return [];
18060
+ }
17705
18061
  if (isBranchActionTarget(state) && context.branchCount) {
17706
18062
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
17707
18063
  }
@@ -17736,10 +18092,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17736
18092
  if (state.activeView === 'history' && state.pendingCommitFocused) {
17737
18093
  return [action({ type: 'unfocusPendingCommit' })];
17738
18094
  }
18095
+ if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
18096
+ return [action({
18097
+ type: 'moveInspectorAction',
18098
+ delta: 1,
18099
+ actionCount: context.inspectorActionCount,
18100
+ })];
18101
+ }
17739
18102
  if (state.focus === 'detail' && context.detailFileCount) {
17740
18103
  return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
17741
18104
  }
17742
18105
  if (state.activeView === 'status' && context.worktreeFileCount) {
18106
+ // Header focused → ↓ re-enters the group at the cursored file
18107
+ // (which is already the group's first file by construction).
18108
+ // Just clear the flag.
18109
+ if (state.statusGroupHeaderFocused) {
18110
+ return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
18111
+ }
17743
18112
  return [action({
17744
18113
  type: 'moveWorktreeFile',
17745
18114
  delta: 1,
@@ -17760,6 +18129,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17760
18129
  previewLineCount: context.previewLineCount,
17761
18130
  })];
17762
18131
  }
18132
+ // Sidebar header focused: ↓ re-enters the list at index 0.
18133
+ // Clears the header flag and snaps the per-entity selection to 0
18134
+ // (mirrors the existing default selection behavior on first
18135
+ // sidebar focus).
18136
+ if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
18137
+ return [action({ type: 'setSidebarHeaderFocused', value: false })];
18138
+ }
17763
18139
  if (isBranchActionTarget(state) && context.branchCount) {
17764
18140
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
17765
18141
  }
@@ -17853,6 +18229,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17853
18229
  ];
17854
18230
  }
17855
18231
  }
18232
+ // Inspector Actions tab: Enter on the cursored action fires its
18233
+ // associated event (cherry-pick / revert / yank / etc.). Wins over
18234
+ // the file-list Enter below when the user has [/]-toggled to the
18235
+ // actions tab. Routes through `getInspectorActionExecuteEvents` so
18236
+ // the per-action dispatch table stays the single source of truth
18237
+ // for what each action does. (#791 follow-up)
18238
+ if (key.return &&
18239
+ state.focus === 'detail' &&
18240
+ state.inspectorTab === 'actions') {
18241
+ const actions = getInspectorActionsForState(state);
18242
+ const cursored = actions[state.inspectorActionIndex];
18243
+ if (cursored) {
18244
+ return getInspectorActionExecuteEvents(cursored, state);
18245
+ }
18246
+ }
17856
18247
  // From the inspector / commit-diff detail panel, Enter opens (or refocuses)
17857
18248
  // the diff view scoped to the currently-selected commit and file. Lets the
17858
18249
  // user drive the explore flow entirely from the right panel: j/k picks a
@@ -17891,7 +18282,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17891
18282
  const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17892
18283
  const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17893
18284
  sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17894
- if (!hasInSidebarPrimaryAction) {
18285
+ // Three cases drill into the dedicated view:
18286
+ // 1. The cursor is on the tab header (user pressed ↑ at the
18287
+ // top of the list to escape the items — Enter explicitly
18288
+ // jumps to the dedicated view).
18289
+ // 2. The tab has no in-sidebar primary action defined (status,
18290
+ // tags, worktrees — drilling in is the canonical path).
18291
+ // 3. The tab has zero items (the dedicated view's empty state
18292
+ // tells the user what to do next).
18293
+ if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
17895
18294
  const tabToView = {
17896
18295
  status: 'status',
17897
18296
  branches: 'branches',
@@ -17911,6 +18310,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17911
18310
  // Fall through — per-entity Enter handler below claims the keystroke.
17912
18311
  }
17913
18312
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
18313
+ // Group header focused → fire the group's batch workflow action.
18314
+ // Routed through the workflow runner so the runtime owns the
18315
+ // git invocation + status messaging consistently with the
18316
+ // single-file `space` toggle. The `payload` carries the group's
18317
+ // state ('staged' / 'unstaged' / 'untracked') so the runtime can
18318
+ // resolve which files to act on without re-deriving group state.
18319
+ if (state.statusGroupHeaderFocused && context.statusGroups) {
18320
+ const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
18321
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
18322
+ if (currentGroup) {
18323
+ const workflowId = currentGroup.state === 'staged'
18324
+ ? 'unstage-all-staged'
18325
+ : currentGroup.state === 'unstaged'
18326
+ ? 'stage-all-unstaged'
18327
+ : 'stage-all-untracked';
18328
+ return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
18329
+ }
18330
+ }
17914
18331
  return [action({
17915
18332
  type: 'navigateOpenDiffForWorktreeFile',
17916
18333
  fileIndex: state.selectedWorktreeFileIndex,
@@ -20653,6 +21070,35 @@ function parseStashDiffFiles(lines) {
20653
21070
  }
20654
21071
  return files;
20655
21072
  }
21073
+ /**
21074
+ * Resolve which stash file *contains* a given line offset — the user's
21075
+ * cursor scrolls through a concatenated multi-file patch, and this is
21076
+ * what powers the "File N/M: <path>" panel header, the inline header
21077
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
21078
+ * dispatchers' "what file is the cursor on" lookup.
21079
+ *
21080
+ * Returns `undefined` when the file list is empty *or* the offset
21081
+ * lands before the very first file's `diff --git` header (e.g. when
21082
+ * `--stat` summary lines lead the patch). Callers fall through to a
21083
+ * "no file selected" state in that case.
21084
+ */
21085
+ function findStashFileForOffset(files, offset) {
21086
+ if (files.length === 0)
21087
+ return undefined;
21088
+ let current;
21089
+ for (const file of files) {
21090
+ if (file.startLine <= offset) {
21091
+ current = file;
21092
+ }
21093
+ else {
21094
+ break;
21095
+ }
21096
+ }
21097
+ // First file is the canonical fallback — even if the offset lands
21098
+ // before its header (rare), we want the cursor to be "in" something
21099
+ // so the user's actions have a target.
21100
+ return current ?? files[0];
21101
+ }
20656
21102
  const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
20657
21103
  function parseDiffGitHeader(line) {
20658
21104
  const match = line.match(DIFF_GIT_HEADER);
@@ -20707,6 +21153,25 @@ function revertFile(git, file) {
20707
21153
  }
20708
21154
  return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
20709
21155
  }
21156
+ /**
21157
+ * Group-level batch ops triggered by Enter on a status group header
21158
+ * (staged / unstaged / untracked). Pass the files belonging to that
21159
+ * group; the helpers run a single `git add` / `git restore --staged`
21160
+ * with all paths in one invocation rather than looping per-file —
21161
+ * faster + atomic from the user's point of view.
21162
+ */
21163
+ function stageAllFiles(git, files) {
21164
+ if (files.length === 0) {
21165
+ return Promise.resolve({ ok: false, message: 'No files to stage' });
21166
+ }
21167
+ return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
21168
+ }
21169
+ function unstageAllFiles(git, files) {
21170
+ if (files.length === 0) {
21171
+ return Promise.resolve({ ok: false, message: 'No files to unstage' });
21172
+ }
21173
+ return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
21174
+ }
20710
21175
 
20711
21176
  function fileState(indexStatus, worktreeStatus) {
20712
21177
  if (indexStatus === '?' && worktreeStatus === '?') {
@@ -20750,6 +21215,22 @@ function applyStatusFilterMask(files, mask) {
20750
21215
  }
20751
21216
  return files.filter((file) => mask[file.state]);
20752
21217
  }
21218
+ const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
21219
+ function groupWorktreeFiles(files) {
21220
+ const groups = [];
21221
+ let cursor = 0;
21222
+ for (const groupState of WORKTREE_GROUP_ORDER) {
21223
+ const groupFiles = files.filter((file) => file.state === groupState);
21224
+ if (groupFiles.length > 0) {
21225
+ groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
21226
+ cursor += groupFiles.length;
21227
+ }
21228
+ }
21229
+ return groups;
21230
+ }
21231
+ function flattenWorktreeGroups(groups) {
21232
+ return groups.flatMap((group) => group.files);
21233
+ }
20753
21234
 
20754
21235
  function hunkHeader(hunk) {
20755
21236
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -23099,88 +23580,6 @@ function formatPullRequestStateLine(pr) {
23099
23580
  return parts.join(' · ');
23100
23581
  }
23101
23582
 
23102
- /**
23103
- * Hardcoded per-entity action lists surfaced inside the right-hand
23104
- * inspector panel. The inspector used to repeat the repo / branch /
23105
- * status content the top header and left sidebar already show; we drop
23106
- * that trailer in favor of an actionable cheat-sheet so the user knows
23107
- * exactly which keystrokes apply to whatever they have under the cursor.
23108
- *
23109
- * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
23110
- * - Most per-entity actions live in `inkInput.ts` as direct keystroke
23111
- * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
23112
- * globally-registered bindings, so the registry would be a partial
23113
- * view at best.
23114
- * - The bindings registry's `contexts` model (normal / search / focus
23115
- * name) does not cleanly map to inspector entity types like "branch"
23116
- * or "tag". Filtering it would mean replicating the same per-view
23117
- * scoping logic the input dispatcher already encodes.
23118
- * - New per-entity actions are added infrequently — the maintenance
23119
- * cost of mirroring them here is low and keeps this file the single
23120
- * source of truth for "what shows in the inspector".
23121
- *
23122
- * If you wire up a new per-entity keystroke in `inkInput.ts` — for
23123
- * example a "create branch from this commit" or "create tag from this
23124
- * commit" action — add the matching row to the relevant array below so
23125
- * it shows up in the inspector automatically.
23126
- */
23127
- const HISTORY_COMMIT_ACTIONS = [
23128
- { key: 'enter', label: 'Open diff' },
23129
- { key: 'c', label: 'Cherry-pick' },
23130
- { key: 'R', label: 'Revert', destructive: true },
23131
- { key: 'Z', label: 'Reset to commit', destructive: true },
23132
- { key: 'i', label: 'Interactive rebase', destructive: true },
23133
- { key: 'y', label: 'Yank hash' },
23134
- { key: 'Y', label: 'Yank short hash' },
23135
- { key: 'O', label: 'Open in browser' },
23136
- ];
23137
- const BRANCH_ACTIONS = [
23138
- { key: 'enter', label: 'Checkout' },
23139
- { key: '+', label: 'New branch' },
23140
- { key: 'R', label: 'Rename' },
23141
- { key: 'u', label: 'Set upstream' },
23142
- { key: 'D', label: 'Delete', destructive: true },
23143
- { key: 'P', label: 'Push current' },
23144
- { key: 'F', label: 'Fetch all' },
23145
- { key: 'y', label: 'Yank name' },
23146
- ];
23147
- const TAG_ACTIONS = [
23148
- { key: '+', label: 'New tag' },
23149
- { key: 'P', label: 'Push tag' },
23150
- { key: 'T', label: 'Delete', destructive: true },
23151
- { key: 'R', label: 'Delete remote', destructive: true },
23152
- { key: 'y', label: 'Yank name' },
23153
- ];
23154
- const STASH_ACTIONS = [
23155
- { key: 'enter', label: 'Open diff' },
23156
- { key: 'a', label: 'Apply' },
23157
- { key: 'p', label: 'Pop' },
23158
- { key: 'X', label: 'Drop', destructive: true },
23159
- { key: 'y', label: 'Yank ref' },
23160
- ];
23161
- const WORKTREE_ACTIONS = [
23162
- { key: 'W', label: 'Remove', destructive: true },
23163
- { key: 'y', label: 'Yank path' },
23164
- ];
23165
- function getInspectorActions(context) {
23166
- switch (context) {
23167
- case 'history-commit':
23168
- return HISTORY_COMMIT_ACTIONS;
23169
- case 'branch':
23170
- return BRANCH_ACTIONS;
23171
- case 'tag':
23172
- return TAG_ACTIONS;
23173
- case 'stash':
23174
- return STASH_ACTIONS;
23175
- case 'worktree':
23176
- return WORKTREE_ACTIONS;
23177
- default: {
23178
- const exhaustive = context;
23179
- throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
23180
- }
23181
- }
23182
- }
23183
-
23184
23583
  function sectionLines(title, diff) {
23185
23584
  const lines = diff.split('\n').map((line) => line.trimEnd());
23186
23585
  return [
@@ -23736,7 +24135,16 @@ function LogInkApp(deps) {
23736
24135
  // count, selected-file resolution, and the rendered list all key off
23737
24136
  // it so toggles never desync the cursor from the rendered rows.
23738
24137
  const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
23739
- const selectedWorktreeFile = visibleWorktreeFiles[state.selectedWorktreeFileIndex];
24138
+ // Sectioned view of the visible files (#791 follow-up). Drives the
24139
+ // status surface's three-tier cursor model: ←/→ jumps between
24140
+ // groups, ↑ at index 0 promotes to the group header, Enter on the
24141
+ // header fires the group's batch action. The renderer also consumes
24142
+ // this so the visible file list stays in canonical group order
24143
+ // regardless of whatever order `git status --porcelain` happens to
24144
+ // emit.
24145
+ const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24146
+ const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24147
+ const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
23740
24148
  const dispatch = React.useCallback((action) => {
23741
24149
  setState((current) => applyLogInkAction(current, action));
23742
24150
  }, []);
@@ -24595,6 +25003,26 @@ function LogInkApp(deps) {
24595
25003
  return { ok: false, message: 'Comment body required' };
24596
25004
  return commentPullRequest(body);
24597
25005
  },
25006
+ // Status surface group-level batch ops (#791 follow-up). The
25007
+ // input handler dispatches these when the user presses Enter on a
25008
+ // group header. We re-derive the file list from the live
25009
+ // `context.worktree?.files` rather than trusting a snapshot —
25010
+ // the worktree may have changed since the keystroke fired (rare,
25011
+ // but the cost of re-filtering is negligible compared to the cost
25012
+ // of a stale add). The mask is honored too so a user who's
25013
+ // hidden a category never has it touched by accident.
25014
+ 'stage-all-unstaged': async () => {
25015
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
25016
+ return stageAllFiles(git, files);
25017
+ },
25018
+ 'unstage-all-staged': async () => {
25019
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
25020
+ return unstageAllFiles(git, files);
25021
+ },
25022
+ 'stage-all-untracked': async () => {
25023
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
25024
+ return stageAllFiles(git, files);
25025
+ },
24598
25026
  };
24599
25027
  const handler = handlers[id];
24600
25028
  if (!handler) {
@@ -24620,7 +25048,7 @@ function LogInkApp(deps) {
24620
25048
  }
24621
25049
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
24622
25050
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
24623
- state.tagSort]);
25051
+ state.statusFilterMask, state.tagSort]);
24624
25052
  // Resolve the active view's "yank target" (commit hash / branch /
24625
25053
  // tag / stash ref / file path) against the live filtered+sorted list,
24626
25054
  // copy it to the system clipboard, and surface the result on the
@@ -24675,7 +25103,7 @@ function LogInkApp(deps) {
24675
25103
  // Read from the mask-filtered list (#776) so the cursor and the
24676
25104
  // yanked path always match what's on screen — yanking a hidden
24677
25105
  // row is always a desync bug.
24678
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25106
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24679
25107
  if (path) {
24680
25108
  value = path;
24681
25109
  label = `path ${path}`;
@@ -24683,7 +25111,7 @@ function LogInkApp(deps) {
24683
25111
  }
24684
25112
  else if (view === 'diff') {
24685
25113
  if (state.diffSource === 'worktree') {
24686
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25114
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24687
25115
  if (path) {
24688
25116
  value = path;
24689
25117
  label = `path ${path}`;
@@ -24693,17 +25121,8 @@ function LogInkApp(deps) {
24693
25121
  // Walk back to the most recent file header at or before the
24694
25122
  // current preview offset — same logic the input-context block
24695
25123
  // uses to expose stashDiffSelectedPath.
24696
- const files = parseStashDiffFiles(stashDiffLines);
24697
- if (files.length > 0) {
24698
- let current = files[0];
24699
- for (const file of files) {
24700
- if (file.startLine <= state.diffPreviewOffset) {
24701
- current = file;
24702
- }
24703
- else {
24704
- break;
24705
- }
24706
- }
25124
+ const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25125
+ if (current) {
24707
25126
  value = current.path;
24708
25127
  label = `path ${current.path}`;
24709
25128
  }
@@ -24757,7 +25176,7 @@ function LogInkApp(deps) {
24757
25176
  state.selectedTagIndex,
24758
25177
  state.selectedWorktreeFileIndex,
24759
25178
  state.tagSort,
24760
- visibleWorktreeFiles,
25179
+ visibleWorktreeFilesGrouped,
24761
25180
  ]);
24762
25181
  React.useEffect(() => {
24763
25182
  let active = true;
@@ -24997,28 +25416,14 @@ function LogInkApp(deps) {
24997
25416
  ? parseStashDiffFiles(stashDiffLines)
24998
25417
  : [];
24999
25418
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
25000
- const stashDiffSelectedPath = (() => {
25001
- if (state.diffSource !== 'stash' || stashDiffFiles.length === 0)
25002
- return undefined;
25003
- const offset = state.diffPreviewOffset;
25004
- // Walk backwards to the most recent file header at or before the
25005
- // current cursor offset.
25006
- let current = stashDiffFiles[0];
25007
- for (const file of stashDiffFiles) {
25008
- if (file.startLine <= offset) {
25009
- current = file;
25010
- }
25011
- else {
25012
- break;
25013
- }
25014
- }
25015
- return current.path;
25016
- })();
25419
+ const stashDiffSelectedPath = state.diffSource === 'stash'
25420
+ ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
25421
+ : undefined;
25017
25422
  getLogInkInputEvents(state, inputValue, key, {
25018
25423
  detailFileCount: detail?.files.length,
25019
25424
  previewLineCount: diffPreviewLineCount,
25020
25425
  worktreeDiffLineCount: worktreeDiff?.lines.length,
25021
- worktreeFileCount: visibleWorktreeFiles.length,
25426
+ worktreeFileCount: visibleWorktreeFilesGrouped.length,
25022
25427
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
25023
25428
  commitDiffHunkOffsets,
25024
25429
  branchCount: branchVisibleCount,
@@ -25028,7 +25433,13 @@ function LogInkApp(deps) {
25028
25433
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
25029
25434
  stashDiffSelectedPath,
25030
25435
  worktreeListCount: worktreeVisibleCount,
25031
- worktreeSelectedPath: visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path,
25436
+ worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
25437
+ statusGroups: visibleWorktreeGroups.map((group) => ({
25438
+ state: group.state,
25439
+ count: group.files.length,
25440
+ startIndex: group.startIndex,
25441
+ })),
25442
+ inspectorActionCount: getInspectorActionsForState(state).length,
25032
25443
  commitDiffSelectedPath: state.diffSource === 'commit'
25033
25444
  ? selectedDetailFile?.path
25034
25445
  : undefined,
@@ -25174,6 +25585,11 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25174
25585
  // Accordion layout — every tab's title is visible on its own line, but
25175
25586
  // only the active tab expands its content underneath. Switching tabs
25176
25587
  // (1-5 / [/]) collapses the previous and expands the next.
25588
+ // When sidebar focus has been promoted to the tab header (#806
25589
+ // follow-up), the active tab's title row gets selection styling
25590
+ // and the items below it render without their cursor highlight
25591
+ // (which now lives on the header).
25592
+ const headerFocused = focused && state.sidebarHeaderFocused;
25177
25593
  const tabBlocks = tabs.flatMap((tab, tabIndex) => {
25178
25594
  const isActive = tab === state.sidebarTab;
25179
25595
  const count = sidebarTabCount(tab, context);
@@ -25181,6 +25597,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25181
25597
  ? `${sidebarTabLabel(tab)} (${count})`
25182
25598
  : sidebarTabLabel(tab);
25183
25599
  const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
25600
+ const headerSelected = isActive && headerFocused;
25184
25601
  const blocks = [];
25185
25602
  if (tabIndex > 0) {
25186
25603
  blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
@@ -25189,6 +25606,12 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25189
25606
  key: `tab-header-${tab}`,
25190
25607
  bold: isActive,
25191
25608
  dimColor: !isActive,
25609
+ // Selection styling on the header itself when the cursor has
25610
+ // been promoted off the items list. inverse swaps fg/bg so the
25611
+ // highlight reads as "this is the cursor target" identically
25612
+ // to how items render when focused.
25613
+ backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
25614
+ inverse: headerSelected,
25192
25615
  }, headerText));
25193
25616
  if (isActive) {
25194
25617
  blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
@@ -25226,7 +25649,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
25226
25649
  // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
25227
25650
  // act on the cursored item without needing to drill into the
25228
25651
  // dedicated view (#791 follow-up — in-sidebar selection).
25229
- const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
25652
+ // Items render with the cursor highlight only when the sidebar is
25653
+ // focused on this tab AND the cursor is on items (not promoted to
25654
+ // the tab header). The header-focused branch up in `renderSidebar`
25655
+ // owns the highlight in that case.
25656
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
25230
25657
  if (tab === 'branches') {
25231
25658
  if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
25232
25659
  return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
@@ -25526,6 +25953,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
25526
25953
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
25527
25954
  }, truncate$1(label, 140));
25528
25955
  }
25956
+ function buildStatusSurfaceRows(groups) {
25957
+ const rows = [];
25958
+ for (const group of groups) {
25959
+ rows.push({ kind: 'header', group });
25960
+ group.files.forEach((file, offset) => {
25961
+ rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
25962
+ });
25963
+ }
25964
+ return rows;
25965
+ }
25529
25966
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25530
25967
  const { Box, Text } = components;
25531
25968
  const focused = state.focus === 'commits';
@@ -25535,26 +25972,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25535
25972
  // uses for j/k navigation. `visibleFiles` may be a strict subset of
25536
25973
  // worktree.files when the user has narrowed via 1/2/3.
25537
25974
  const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
25975
+ // Group + canonical-sort. The runtime + input handler agree on this
25976
+ // order so a `selectedWorktreeFileIndex` of N always points to the
25977
+ // same file across all three (renderer / input / workflow handlers).
25978
+ const visibleGroups = groupWorktreeFiles(visibleFiles);
25979
+ const surfaceRows = buildStatusSurfaceRows(visibleGroups);
25538
25980
  const listRows = Math.max(4, bodyRows - 5);
25539
25981
  const selectedIndex = state.selectedWorktreeFileIndex;
25982
+ const headerFocused = state.statusGroupHeaderFocused;
25983
+ // Resolve the cursor's row index in the flat (header-and-file) row
25984
+ // list. Used to window the visible slice around the cursor.
25985
+ const cursorRowIndex = (() => {
25986
+ if (!surfaceRows.length)
25987
+ return 0;
25988
+ const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
25989
+ if (!currentGroup)
25990
+ return 0;
25991
+ if (headerFocused) {
25992
+ const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
25993
+ return idx >= 0 ? idx : 0;
25994
+ }
25995
+ const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
25996
+ return idx >= 0 ? idx : 0;
25997
+ })();
25540
25998
  const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
25541
- const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
25999
+ const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
25542
26000
  const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
25543
- const fileRows = isLoading || !visibleFiles.length
26001
+ const renderedRows = isLoading || !surfaceRows.length
25544
26002
  ? []
25545
- : visibleFiles.slice(startIndex).slice(0, listRows).map((file, offset) => {
25546
- const index = startIndex + offset;
25547
- const isSelected = index === selectedIndex;
26003
+ : surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
26004
+ const rowIndex = windowStart + offset;
26005
+ if (row.kind === 'header') {
26006
+ const groupContainsCursor = selectedIndex >= row.group.startIndex &&
26007
+ selectedIndex < row.group.startIndex + row.group.files.length;
26008
+ const headerSelected = focused && headerFocused && groupContainsCursor;
26009
+ const arrow = theme.ascii ? '>' : '▾';
26010
+ const groupLabel = capitalizeGroupName(row.group.state);
26011
+ const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
26012
+ return h(Text, {
26013
+ key: `status-group-${row.group.state}-${rowIndex}`,
26014
+ bold: true,
26015
+ dimColor: !headerSelected && rowIndex > cursorRowIndex,
26016
+ backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
26017
+ inverse: headerSelected,
26018
+ }, truncate$1(text, 140));
26019
+ }
26020
+ const isSelected = !headerFocused && row.flatIndex === selectedIndex;
25548
26021
  const cursorPart = `${isSelected ? '>' : ' '} `;
25549
- const dotColor = getStageStatusDotColor(file.state, theme);
26022
+ const dotColor = getStageStatusDotColor(row.file.state, theme);
25550
26023
  const useDot = dotColor !== undefined;
25551
26024
  const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
25552
- const tail = `${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
25553
- const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
26025
+ const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
26026
+ const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
25554
26027
  return h(Text, {
25555
- key: `status-row-${index}`,
25556
- dimColor: offset > 0,
25557
- }, cursorPart, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
26028
+ key: `status-file-${row.flatIndex}-${rowIndex}`,
26029
+ dimColor: !isSelected && rowIndex > cursorRowIndex,
26030
+ backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
26031
+ inverse: isSelected && focused,
26032
+ }, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
25558
26033
  });
25559
26034
  // When the mask narrows the list to nothing but the underlying repo
25560
26035
  // is non-clean, surface why the panel looks empty so the user can
@@ -25584,11 +26059,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25584
26059
  // never touch the filter.
25585
26060
  ...(isStatusFilterMaskActive(state.statusFilterMask)
25586
26061
  ? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
25587
- : []), ...fileRows, ...fallbackLines.map((line, index) => h(Text, {
26062
+ : []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
25588
26063
  key: `status-surface-fallback-${index}`,
25589
26064
  dimColor: index > 0,
25590
26065
  }, truncate$1(line, 140))));
25591
26066
  }
26067
+ function capitalizeGroupName(value) {
26068
+ return value.charAt(0).toUpperCase() + value.slice(1);
26069
+ }
25592
26070
  function isStatusFilterMaskActive(mask) {
25593
26071
  return !mask.staged || !mask.unstaged || !mask.untracked;
25594
26072
  }
@@ -26038,20 +26516,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
26038
26516
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
26039
26517
  const stashFiles = parseStashDiffFiles(lines);
26040
26518
  const fileCount = stashFiles.length;
26041
- const currentFile = (() => {
26042
- if (fileCount === 0)
26043
- return undefined;
26044
- let current = stashFiles[0];
26045
- for (const file of stashFiles) {
26046
- if (file.startLine <= state.diffPreviewOffset) {
26047
- current = file;
26048
- }
26049
- else {
26050
- break;
26051
- }
26052
- }
26053
- return current;
26054
- })();
26519
+ const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
26055
26520
  const currentFileIndex = currentFile
26056
26521
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
26057
26522
  : -1;
@@ -26078,14 +26543,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
26078
26543
  const headerLines = splitRequestedButTooNarrow
26079
26544
  ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26080
26545
  : baseHeaderLines;
26546
+ // File header anchor map: absolute line index → owning stash file.
26547
+ // Lets the body-render pass restyle each `diff --git` row in O(1)
26548
+ // and decide which one is the *active* file (the one currently
26549
+ // containing `diffPreviewOffset`). The active header gets the
26550
+ // selection background to mark "the file the cursor is inside."
26551
+ const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
26552
+ const activeStartLine = currentFile?.startLine;
26081
26553
  const stashBodyNodes = stashDiffLoading || !lines.length
26082
26554
  ? []
26083
26555
  : splitActive
26084
26556
  ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
26085
- : visibleLines.map((line, index) => h(Text, {
26086
- key: `stash-diff-line-${state.diffPreviewOffset + index}`,
26087
- ...diffLineProps(line, theme),
26088
- }, truncate$1(line, width - 4)));
26557
+ : visibleLines.map((line, index) => {
26558
+ const absoluteIndex = state.diffPreviewOffset + index;
26559
+ const headerFile = stashFileByStartLine.get(absoluteIndex);
26560
+ if (headerFile) {
26561
+ // Replace the verbose `diff --git a/<path> b/<path>` text
26562
+ // with a compact `▾ <path>` marker — the path itself is
26563
+ // the meaningful identifier, not the a/b duplication. The
26564
+ // active file's header gets selection styling so the user
26565
+ // sees at a glance which file the cursor is inside.
26566
+ const isActive = absoluteIndex === activeStartLine;
26567
+ const arrow = theme.ascii ? '> ' : '▾ ';
26568
+ return h(Text, {
26569
+ key: `stash-diff-line-${absoluteIndex}`,
26570
+ bold: true,
26571
+ color: theme.noColor ? undefined : theme.colors.accent,
26572
+ backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
26573
+ inverse: isActive && focused,
26574
+ }, truncate$1(`${arrow}${headerFile.path}`, width - 4));
26575
+ }
26576
+ return h(Text, {
26577
+ key: `stash-diff-line-${absoluteIndex}`,
26578
+ ...diffLineProps(line, theme),
26579
+ }, truncate$1(line, width - 4));
26580
+ });
26089
26581
  return h(Box, {
26090
26582
  borderColor: focusBorderColor(theme, focused),
26091
26583
  borderStyle: theme.borderStyle,
@@ -26268,7 +26760,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26268
26760
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
26269
26761
  key: `detail-${index}`,
26270
26762
  dimColor: index > 1,
26271
- }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26763
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26764
+ cursorIndex: state.inspectorActionIndex,
26765
+ cursorActive: focused && state.inspectorTab === 'actions',
26766
+ }));
26272
26767
  }
26273
26768
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
26274
26769
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -26305,22 +26800,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26305
26800
  h(Text, { key: 'detail-spacer-3' }, ''),
26306
26801
  h(Text, { key: 'detail-files-title' }, 'Changed files:'),
26307
26802
  ];
26803
+ // Single-cursor invariant: the file list owns the cursor when the
26804
+ // inspector tab is active; the actions list owns it when the actions
26805
+ // tab is active. Pass `focused` only for the matching tab so users
26806
+ // never see two simultaneous selection highlights inside the panel.
26807
+ const fileListFocused = focused && state.inspectorTab === 'inspector';
26308
26808
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
26309
- const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
26310
- // Tabbed mode (#806 follow-up short terminals): render only the
26311
- // active inspector tab with a `[Inspector] Actions` header so the
26312
- // user knows what they're seeing and how to switch (`[/]` while
26313
- // focus is on the inspector). Tall terminals stack both sections
26314
- // as before.
26809
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
26810
+ // Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
26811
+ // tall-stacked mode so the user can always see which tab the cursor
26812
+ // owns and learn the `[/]` toggle. Without this on tall terminals,
26813
+ // the actions list looked like a static cheat-sheet there was no
26814
+ // visible signal that the cursor could move into it.
26815
+ //
26816
+ // Spacing between tab labels comes from the labels' own padding
26817
+ // (the active label is bracketed `[Inspector]` while the inactive
26818
+ // one is space-padded ` Inspector `, so adjacency reads cleanly).
26819
+ // Earlier revisions stuck a raw `' '` between the Text children to
26820
+ // pad them visually — that crashes Ink at first paint with
26821
+ // "Text string ' ' must be rendered inside <Text> component"
26822
+ // because Box only accepts component children, never bare strings.
26823
+ const activeTab = state.inspectorTab;
26824
+ const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26825
+ bold: activeTab === 'inspector',
26826
+ dimColor: activeTab !== 'inspector',
26827
+ }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
26828
+ bold: activeTab === 'actions',
26829
+ dimColor: activeTab !== 'actions',
26830
+ }, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
26831
+ ? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
26832
+ : []));
26833
+ // Tabbed mode (short terminals): render only the active tab's
26834
+ // content under the tab header.
26315
26835
  if (tabbed) {
26316
- const activeTab = state.inspectorTab;
26317
- const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26318
- bold: activeTab === 'inspector',
26319
- dimColor: activeTab !== 'inspector',
26320
- }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
26321
- bold: activeTab === 'actions',
26322
- dimColor: activeTab !== 'actions',
26323
- }, activeTab === 'actions' ? '[Actions]' : ' Actions '));
26324
26836
  return h(Box, {
26325
26837
  borderColor: focusBorderColor(theme, focused),
26326
26838
  borderStyle: theme.borderStyle,
@@ -26329,15 +26841,24 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26329
26841
  paddingX: 1,
26330
26842
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26331
26843
  ? [...headerNodes, ...fileListNodes]
26332
- : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26333
- }
26844
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26845
+ cursorIndex: state.inspectorActionIndex,
26846
+ cursorActive: focused,
26847
+ })));
26848
+ }
26849
+ // Tall mode: stack both sections so the user can read everything at
26850
+ // once, but show the tab header so the active section (and the
26851
+ // `[/]` switch affordance) is visible.
26334
26852
  return h(Box, {
26335
26853
  borderColor: focusBorderColor(theme, focused),
26336
26854
  borderStyle: theme.borderStyle,
26337
26855
  flexDirection: 'column',
26338
26856
  width,
26339
26857
  paddingX: 1,
26340
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26858
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26859
+ cursorIndex: state.inspectorActionIndex,
26860
+ cursorActive: focused && state.inspectorTab === 'actions',
26861
+ }));
26341
26862
  }
26342
26863
  /**
26343
26864
  * Render the trailing "Actions:" section that surfaces which keystrokes
@@ -26350,7 +26871,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26350
26871
  * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26351
26872
  * collides with the next row.
26352
26873
  */
26353
- function renderInspectorActionsSection(h, Text, context, width, theme) {
26874
+ function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
26354
26875
  const actions = getInspectorActions(context);
26355
26876
  if (!actions.length)
26356
26877
  return [];
@@ -26361,10 +26882,13 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
26361
26882
  const GAP = ' ';
26362
26883
  const DESTRUCTIVE_SUFFIX = ' [!]';
26363
26884
  const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26885
+ const cursorIndex = options.cursorIndex ?? 0;
26886
+ const cursorActive = options.cursorActive ?? false;
26364
26887
  const nodes = [
26365
26888
  h(Text, { key: 'actions-spacer' }, ''),
26366
- h(Text, { key: 'actions-title' }, 'Actions:'),
26889
+ h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
26367
26890
  ...actions.map((action, index) => {
26891
+ const isSelected = cursorActive && index === cursorIndex;
26368
26892
  const keyCell = action.key.padEnd(KEY_COLUMN);
26369
26893
  const label = truncate$1(action.label, labelBudget);
26370
26894
  const children = [
@@ -26382,7 +26906,11 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
26382
26906
  dimColor: false,
26383
26907
  }, DESTRUCTIVE_SUFFIX));
26384
26908
  }
26385
- return h(Text, { key: `actions-${index}` }, ...children);
26909
+ return h(Text, {
26910
+ key: `actions-${index}`,
26911
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
26912
+ inverse: isSelected,
26913
+ }, ...children);
26386
26914
  }),
26387
26915
  ];
26388
26916
  return nodes;