git-coco 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.esm.mjs +577 -130
  2. package/dist/index.js +577 -130
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.38.0";
81
+ const BUILD_VERSION = "0.39.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14772,84 +14772,6 @@ function formatInkRefLabels(refs) {
14772
14772
  return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
14773
14773
  }
14774
14774
 
14775
- function countLabel(count, singular, plural = `${singular}s`) {
14776
- return `${count} ${count === 1 ? singular : plural}`;
14777
- }
14778
- function getLogInkWorkflowSections(context) {
14779
- const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
14780
- const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
14781
- const loading = context.contextLoading;
14782
- const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
14783
- const repository = context.provider?.repository;
14784
- const repoName = repository?.owner && repository.name
14785
- ? `${repository.owner}/${repository.name}`
14786
- : repository?.message || 'local repository';
14787
- const operation = context.operation;
14788
- const worktree = context.worktree;
14789
- return [
14790
- {
14791
- title: 'Branch',
14792
- lines: [
14793
- `Current: ${currentBranch}`,
14794
- `State: ${dirty}`,
14795
- loading && !context.branches
14796
- ? 'Branch data loading'
14797
- : context.branches
14798
- ? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
14799
- : 'Branch data unavailable',
14800
- ],
14801
- },
14802
- {
14803
- title: 'Provider / PR',
14804
- lines: [
14805
- `Repository: ${repoName}`,
14806
- loading && !context.provider && !context.pullRequest
14807
- ? 'Provider and pull request data loading'
14808
- : currentPullRequest
14809
- ? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
14810
- : 'No pull request detected for current branch',
14811
- context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
14812
- ],
14813
- },
14814
- {
14815
- title: 'Status',
14816
- lines: loading && !worktree
14817
- ? ['Status data loading']
14818
- : worktree
14819
- ? [
14820
- `${countLabel(worktree.stagedCount, 'staged file')}`,
14821
- `${countLabel(worktree.unstagedCount, 'unstaged file')}`,
14822
- `${countLabel(worktree.untrackedCount, 'untracked file')}`,
14823
- ]
14824
- : ['Status data unavailable'],
14825
- },
14826
- {
14827
- title: 'Tags / Stashes / Worktrees',
14828
- lines: [
14829
- loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
14830
- loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
14831
- context.worktreeList
14832
- ? countLabel(context.worktreeList.worktrees.length, 'worktree')
14833
- : loading
14834
- ? 'Worktrees loading'
14835
- : 'Worktrees unavailable',
14836
- ],
14837
- },
14838
- {
14839
- title: 'Operation / AI',
14840
- lines: [
14841
- loading && !operation
14842
- ? 'Operation data loading'
14843
- : operation?.operation
14844
- ? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
14845
- : 'No merge, rebase, cherry-pick, or revert in progress',
14846
- context.selectedCommit
14847
- ? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
14848
- : 'AI actions require a selected commit',
14849
- ],
14850
- },
14851
- ];
14852
- }
14853
14775
  function getLogInkWorkflowActions() {
14854
14776
  return [
14855
14777
  {
@@ -15093,6 +15015,38 @@ function getLogInkWorkflowActions() {
15093
15015
  kind: 'destructive',
15094
15016
  requiresConfirmation: true,
15095
15017
  },
15018
+ {
15019
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15020
+ // The prompt itself is the affirmative gate — the user has to
15021
+ // type a branch name before anything happens — so this skips the
15022
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15023
+ // palette path can't synthesize a branch name and surfaces a
15024
+ // hint instead.
15025
+ //
15026
+ // Distinct from `create-branch` (palette / `+` on branches view),
15027
+ // which uses `git switch -c` and switches onto the new branch.
15028
+ // This workflow uses `git branch <name> <sha>` and stays put —
15029
+ // GitKraken's "create branch here" semantic.
15030
+ id: 'create-branch-here',
15031
+ key: '',
15032
+ label: 'Create branch from commit',
15033
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15034
+ kind: 'normal',
15035
+ requiresConfirmation: false,
15036
+ },
15037
+ {
15038
+ // Per-view-only: scoped to the history view in inkInput via the
15039
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15040
+ // Same prompt-as-confirmation pattern as create-branch-here.
15041
+ // Lightweight tag — annotated tags remain available through the
15042
+ // existing `+` flow on the tags view.
15043
+ id: 'create-tag-here',
15044
+ key: '',
15045
+ label: 'Create tag at commit',
15046
+ description: 'Create a lightweight tag at the cursored commit.',
15047
+ kind: 'normal',
15048
+ requiresConfirmation: false,
15049
+ },
15096
15050
  {
15097
15051
  // Per-view-only: scoped to the history view in inkInput. `i`
15098
15052
  // (lowercase) is used instead of `I` so the existing `I`
@@ -15105,6 +15059,38 @@ function getLogInkWorkflowActions() {
15105
15059
  kind: 'destructive',
15106
15060
  requiresConfirmation: true,
15107
15061
  },
15062
+ {
15063
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15064
+ // The prompt itself is the affirmative gate — the user has to
15065
+ // type a branch name before anything happens — so this skips the
15066
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15067
+ // palette path can't synthesize a branch name and surfaces a
15068
+ // hint instead.
15069
+ //
15070
+ // Distinct from `create-branch` (palette / `+` on branches view),
15071
+ // which uses `git switch -c` and switches onto the new branch.
15072
+ // This workflow uses `git branch <name> <sha>` and stays put —
15073
+ // GitKraken's "create branch here" semantic.
15074
+ id: 'create-branch-here',
15075
+ key: '',
15076
+ label: 'Create branch from commit',
15077
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15078
+ kind: 'normal',
15079
+ requiresConfirmation: false,
15080
+ },
15081
+ {
15082
+ // Per-view-only: scoped to the history view in inkInput via the
15083
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15084
+ // Same prompt-as-confirmation pattern as create-branch-here.
15085
+ // Lightweight tag — annotated tags remain available through the
15086
+ // existing `+` flow on the tags view.
15087
+ id: 'create-tag-here',
15088
+ key: '',
15089
+ label: 'Create tag at commit',
15090
+ description: 'Create a lightweight tag at the cursored commit.',
15091
+ kind: 'normal',
15092
+ requiresConfirmation: false,
15093
+ },
15108
15094
  {
15109
15095
  id: 'ai-commit-summary',
15110
15096
  key: 'I',
@@ -15639,9 +15625,12 @@ function getLogInkFooterHints(options) {
15639
15625
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15640
15626
  // revert, `Z` reset, `i` interactive-rebase) all route through a
15641
15627
  // y-confirm or mode prompt — none fire silently from the keystroke.
15642
- // Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
15643
- // scannable; full descriptions live in `?` help and the palette.
15644
- contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15628
+ // `B` create-branch-here and `gT` create-tag-here use a prompt as
15629
+ // the affirmative gate (typing the name is the confirmation).
15630
+ // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
15631
+ // the footer stays scannable; full descriptions live in `?` help
15632
+ // and the palette.
15633
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15645
15634
  global: NORMAL_GLOBAL_HINTS,
15646
15635
  };
15647
15636
  }
@@ -15952,23 +15941,31 @@ function cycleBranchSort(mode) {
15952
15941
  return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
15953
15942
  }
15954
15943
  function sortBranches(branches, mode) {
15955
- const copy = branches.slice();
15944
+ // Pin the current branch at index 0 regardless of sort mode (#806
15945
+ // follow-up). Lands the user's cursor on the active branch by
15946
+ // default and keeps the most-relevant row glued to the top of the
15947
+ // list as they cycle sorts.
15948
+ const current = branches.find((entry) => entry.current);
15949
+ const rest = branches.filter((entry) => !entry.current);
15950
+ const sortedRest = rest.slice();
15956
15951
  switch (mode) {
15957
15952
  case 'name':
15958
- return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
15953
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
15954
+ break;
15959
15955
  case 'recent':
15960
15956
  // ISO-shaped dates compare byte-for-byte; descending so the freshest
15961
15957
  // branch sits at the top.
15962
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15958
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15963
15959
  a.shortName.localeCompare(b.shortName));
15960
+ break;
15964
15961
  case 'ahead':
15965
15962
  // ahead-first; ties broken by behind, then by name. Keeps "this branch
15966
15963
  // has unmerged work" in the user's first scroll.
15967
- return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15964
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15968
15965
  a.shortName.localeCompare(b.shortName));
15969
- default:
15970
- return copy;
15966
+ break;
15971
15967
  }
15968
+ return current ? [current, ...sortedRest] : sortedRest;
15972
15969
  }
15973
15970
  const TAG_SORT_MODES = ['recent', 'name'];
15974
15971
  const DEFAULT_TAG_SORT_MODE = 'recent';
@@ -16303,6 +16300,7 @@ function createLogInkState(rows, options = {}) {
16303
16300
  userSidebarTab: 'status',
16304
16301
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16305
16302
  diffViewMode: 'unified',
16303
+ inspectorTab: 'inspector',
16306
16304
  };
16307
16305
  }
16308
16306
  function getSelectedInkCommit(state) {
@@ -16360,6 +16358,28 @@ function applyLogInkAction(state, action) {
16360
16358
  pendingCommitFocused: false,
16361
16359
  pendingKey: undefined,
16362
16360
  };
16361
+ case 'selectCommitByHash': {
16362
+ // Locates a commit by its full or short hash within the active
16363
+ // filtered list and snaps the cursor to it. Used by the
16364
+ // branch/tag auto-jump effect (#806 follow-up): cursoring a
16365
+ // branch in the sidebar tracks the history view to that
16366
+ // branch's tip without the user manually scrolling. No-op when
16367
+ // the hash isn't in the loaded list (the runtime surfaces a
16368
+ // status hint in that case).
16369
+ const target = action.hash;
16370
+ const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
16371
+ if (index < 0) {
16372
+ return state;
16373
+ }
16374
+ return {
16375
+ ...state,
16376
+ selectedIndex: index,
16377
+ selectedFileIndex: 0,
16378
+ diffPreviewOffset: 0,
16379
+ pendingCommitFocused: false,
16380
+ pendingKey: undefined,
16381
+ };
16382
+ }
16363
16383
  case 'focusPendingCommit':
16364
16384
  return {
16365
16385
  ...state,
@@ -16399,6 +16419,34 @@ function applyLogInkAction(state, action) {
16399
16419
  selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
16400
16420
  pendingKey: undefined,
16401
16421
  };
16422
+ case 'resetBranchSelection':
16423
+ // Snap the branches sidebar / view cursor back to position 0.
16424
+ // Used after a successful checkout (#806 follow-up): combined
16425
+ // with the "current branch pinned at top" rule from #809, this
16426
+ // lands the user's cursor on the just-checked-out branch.
16427
+ return {
16428
+ ...state,
16429
+ selectedBranchIndex: 0,
16430
+ pendingKey: undefined,
16431
+ };
16432
+ case 'setInspectorTab':
16433
+ return {
16434
+ ...state,
16435
+ inspectorTab: action.value,
16436
+ pendingKey: undefined,
16437
+ };
16438
+ case 'cycleInspectorTab': {
16439
+ // Two-tab toggle — `delta` is symmetrical so direction does not
16440
+ // matter, but we keep the action shape consistent with the
16441
+ // sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
16442
+ // can mirror the sidebar pattern verbatim.
16443
+ const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
16444
+ return {
16445
+ ...state,
16446
+ inspectorTab: next,
16447
+ pendingKey: undefined,
16448
+ };
16449
+ }
16402
16450
  case 'moveTag':
16403
16451
  return {
16404
16452
  ...state,
@@ -17484,6 +17532,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17484
17532
  action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
17485
17533
  ];
17486
17534
  }
17535
+ // `gT` chord: create a lightweight tag at the cursored commit on the
17536
+ // history view. Bare `T` is taken (delete-tag on the tags view) so we
17537
+ // use the chord. Mirrors `gH` exactly — uppercase letter after the
17538
+ // `g` chord prefix, distinct from the lowercase `gt` chord which
17539
+ // jumps to the tags view. The prompt is the affirmative gate.
17540
+ if (state.pendingKey === 'g' && inputValue === 'T') {
17541
+ if (state.activeView === 'history' &&
17542
+ state.focus === 'commits' &&
17543
+ state.filteredCommits.length > 0 &&
17544
+ !state.pendingCommitFocused) {
17545
+ return [
17546
+ action({ type: 'setPendingKey', value: undefined }),
17547
+ action({
17548
+ type: 'openInputPrompt',
17549
+ kind: 'create-tag-here',
17550
+ label: 'New tag name (at cursored commit)',
17551
+ }),
17552
+ ];
17553
+ }
17554
+ return [
17555
+ action({ type: 'setPendingKey', value: undefined }),
17556
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
17557
+ ];
17558
+ }
17487
17559
  if (inputValue === 'g') {
17488
17560
  if (state.pendingKey === 'g') {
17489
17561
  return [
@@ -17565,6 +17637,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17565
17637
  hunkOffsets: context.commitDiffHunkOffsets,
17566
17638
  })];
17567
17639
  }
17640
+ // Inspector focused: cycle the inspector tab. The renderer only
17641
+ // honors the tab field on short terminals (where the inspector
17642
+ // collapses into a tabbed layout), but we let the user pre-set
17643
+ // their preference on tall terminals too.
17644
+ if (state.focus === 'detail') {
17645
+ return [action({ type: 'cycleInspectorTab', delta: -1 })];
17646
+ }
17568
17647
  return [action({ type: 'previousSidebarTab' })];
17569
17648
  }
17570
17649
  if (inputValue === ']') {
@@ -17589,6 +17668,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17589
17668
  hunkOffsets: context.commitDiffHunkOffsets,
17590
17669
  })];
17591
17670
  }
17671
+ if (state.focus === 'detail') {
17672
+ return [action({ type: 'cycleInspectorTab', delta: 1 })];
17673
+ }
17592
17674
  return [action({ type: 'nextSidebarTab' })];
17593
17675
  }
17594
17676
  // Status surface intercepts 1/2/3 before the sidebar-tab numeric
@@ -18088,6 +18170,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18088
18170
  !state.pendingCommitFocused) {
18089
18171
  return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18090
18172
  }
18173
+ // `B` opens a create-branch prompt rooted at the cursored commit
18174
+ // (`git branch <name> <sha>` — does NOT switch to the new branch).
18175
+ // The prompt itself is the affirmative gate, so no separate y-confirm.
18176
+ // Bare uppercase `B` since the lowercase `b` is used by the `gb`
18177
+ // chord prefix and we want a single keystroke for this common op.
18178
+ if (inputValue === 'B' &&
18179
+ state.activeView === 'history' &&
18180
+ state.focus === 'commits' &&
18181
+ state.filteredCommits.length > 0 &&
18182
+ !state.pendingCommitFocused) {
18183
+ return [action({
18184
+ type: 'openInputPrompt',
18185
+ kind: 'create-branch-here',
18186
+ label: 'New branch name (at cursored commit)',
18187
+ })];
18188
+ }
18091
18189
  // `y` / `Y` yank the contextually relevant identifier from the active
18092
18190
  // view to the system clipboard:
18093
18191
  // history → cursored commit hash (Y for short hash)
@@ -18714,10 +18812,25 @@ const LOG_INK_MIN_COLUMNS = 80;
18714
18812
  const LOG_INK_MIN_ROWS = 24;
18715
18813
  const LOG_INK_DEFAULT_COLUMNS = 120;
18716
18814
  const LOG_INK_DEFAULT_ROWS = 40;
18815
+ /**
18816
+ * Terminal-row threshold below which the inspector switches to a
18817
+ * tabbed layout (commit-detail vs actions). Picked empirically: at
18818
+ * 28 rows the inspector's full stack (~30 rows when fully populated)
18819
+ * starts clipping the actions section; below that, the tabbed mode
18820
+ * gives both views their own air.
18821
+ */
18822
+ const INSPECTOR_TABBED_BELOW_ROWS = 28;
18717
18823
  function getLogInkLayout(input) {
18718
18824
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
18719
18825
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
18720
- const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
18826
+ // Inspector width at rest 20-32 cells (~22% of width), focused
18827
+ // 36-60 cells (~40% of width). Narrow rest state keeps the commit
18828
+ // graph dominant; focus expansion gives the inspector room for long
18829
+ // commit bodies / file lists / action labels. Mirrors the sidebar
18830
+ // pattern (sidebarFocused above): instant transition per render.
18831
+ const detailWidth = input.inspectorFocused
18832
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
18833
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
18721
18834
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
18722
18835
  // (~36% of width). The transition is instant per render — focus tab to
18723
18836
  // expand, focus away to collapse.
@@ -18732,6 +18845,7 @@ function getLogInkLayout(input) {
18732
18845
  rows,
18733
18846
  sidebarWidth,
18734
18847
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
18848
+ inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
18735
18849
  };
18736
18850
  }
18737
18851
 
@@ -20227,6 +20341,63 @@ function resetToCommit(git, commit, mode) {
20227
20341
  : result.details,
20228
20342
  }));
20229
20343
  }
20344
+ /**
20345
+ * Create a new local branch pointed at <commit>, without switching to it.
20346
+ *
20347
+ * This is the "create branch from cursored commit" history action — the
20348
+ * user types the new branch name into an input prompt and we run
20349
+ * `git branch <name> <sha>` (NOT `git switch -c`, which is what
20350
+ * `branchActions.createBranch` does for the create-branch-at-HEAD flow).
20351
+ * The split exists because GitKraken-style "create branch here" is
20352
+ * specifically about marking a historical commit, not about switching
20353
+ * onto a new working branch.
20354
+ *
20355
+ * Note for the inspector follow-up: workflow surfacing is driven by the
20356
+ * registry in `inkWorkflows.ts`, not a hardcoded action list — adding
20357
+ * `create-branch-here` there is enough for the inspector / palette to
20358
+ * pick this up.
20359
+ */
20360
+ function createBranchFromCommit(git, name, commit) {
20361
+ const trimmedName = name.trim();
20362
+ if (!commit) {
20363
+ return Promise.resolve({
20364
+ ok: false,
20365
+ message: 'No commit selected.',
20366
+ });
20367
+ }
20368
+ if (!trimmedName) {
20369
+ return Promise.resolve({
20370
+ ok: false,
20371
+ message: 'Branch name required.',
20372
+ });
20373
+ }
20374
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
20375
+ }
20376
+ /**
20377
+ * Create a lightweight tag pointed at <commit>.
20378
+ *
20379
+ * Mirrors `createBranchFromCommit` for the tag side: the user types a
20380
+ * tag name into an input prompt and we run `git tag <name> <sha>`
20381
+ * (lightweight, no `-a`/`-m`). Annotated tags remain available through
20382
+ * the existing `+` flow on the tags view; this is the per-commit
20383
+ * shortcut.
20384
+ */
20385
+ function createTagAtCommit(git, name, commit) {
20386
+ const trimmedName = name.trim();
20387
+ if (!commit) {
20388
+ return Promise.resolve({
20389
+ ok: false,
20390
+ message: 'No commit selected.',
20391
+ });
20392
+ }
20393
+ if (!trimmedName) {
20394
+ return Promise.resolve({
20395
+ ok: false,
20396
+ message: 'Tag name required.',
20397
+ });
20398
+ }
20399
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
20400
+ }
20230
20401
  function startInteractiveRebase(git, commit) {
20231
20402
  if (!commit) {
20232
20403
  return Promise.resolve({
@@ -22953,6 +23124,88 @@ function formatPullRequestStateLine(pr) {
22953
23124
  return parts.join(' · ');
22954
23125
  }
22955
23126
 
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
+
22956
23209
  function sectionLines(title, diff) {
22957
23210
  const lines = diff.split('\n').map((line) => line.trimEnd());
22958
23211
  return [
@@ -23752,6 +24005,80 @@ function LogInkApp(deps) {
23752
24005
  active = false;
23753
24006
  };
23754
24007
  }, [git, selected?.hash]);
24008
+ // #806 follow-up — auto-jump the history view to whichever branch /
24009
+ // tag the user is currently cursoring in the sidebar (or the
24010
+ // dedicated branches / tags view). Debounced so cursor-scrolling
24011
+ // through a long branch list doesn't dispatch on every keystroke.
24012
+ // No-op when the cursored ref's tip isn't in the loaded commit
24013
+ // window (under compact mode the cursored branch's tip may not be
24014
+ // fetched yet); a status hint surfaces in that case so the user
24015
+ // knows to toggle full graph or load older commits.
24016
+ React.useEffect(() => {
24017
+ const onBranchTab = state.activeView === 'branches' ||
24018
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24019
+ const onTagTab = state.activeView === 'tags' ||
24020
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24021
+ if (!onBranchTab && !onTagTab)
24022
+ return;
24023
+ let cancelled = false;
24024
+ const timer = setTimeout(() => {
24025
+ if (cancelled)
24026
+ return;
24027
+ let targetHash;
24028
+ let targetLabel;
24029
+ if (onBranchTab) {
24030
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24031
+ const visible = state.filter
24032
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24033
+ : all;
24034
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24035
+ if (branch) {
24036
+ targetHash = branch.hash;
24037
+ targetLabel = `branch ${branch.shortName}`;
24038
+ }
24039
+ }
24040
+ else if (onTagTab) {
24041
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24042
+ const visible = state.filter
24043
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24044
+ : all;
24045
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24046
+ if (tag) {
24047
+ targetHash = tag.hash;
24048
+ targetLabel = `tag ${tag.name}`;
24049
+ }
24050
+ }
24051
+ if (!targetHash)
24052
+ return;
24053
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24054
+ if (loaded) {
24055
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24056
+ // Confirmation status message so the user gets feedback even
24057
+ // when the dedicated branches / tags view is occupying the
24058
+ // main panel and the history cursor moves invisibly behind it.
24059
+ dispatch({
24060
+ type: 'setStatus',
24061
+ value: `Synced history to ${targetLabel} tip`,
24062
+ });
24063
+ }
24064
+ else {
24065
+ dispatch({
24066
+ type: 'setStatus',
24067
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24068
+ });
24069
+ }
24070
+ }, 150);
24071
+ return () => {
24072
+ cancelled = true;
24073
+ clearTimeout(timer);
24074
+ };
24075
+ }, [
24076
+ dispatch, context.branches, context.tags,
24077
+ state.activeView, state.focus, state.sidebarTab,
24078
+ state.selectedBranchIndex, state.selectedTagIndex,
24079
+ state.branchSort, state.tagSort, state.filter,
24080
+ state.filteredCommits,
24081
+ ]);
23755
24082
  React.useEffect(() => {
23756
24083
  let active = true;
23757
24084
  async function loadWorktreeDiff() {
@@ -24127,6 +24454,30 @@ function LogInkApp(deps) {
24127
24454
  message: commit.message,
24128
24455
  });
24129
24456
  },
24457
+ 'create-branch-here': async () => {
24458
+ const commit = getSelectedInkCommit(state);
24459
+ const name = payload?.trim();
24460
+ if (!commit)
24461
+ return { ok: false, message: 'No commit selected' };
24462
+ if (!name)
24463
+ return { ok: false, message: 'Branch name required' };
24464
+ return createBranchFromCommit(git, name, {
24465
+ hash: commit.hash,
24466
+ shortHash: commit.shortHash,
24467
+ });
24468
+ },
24469
+ 'create-tag-here': async () => {
24470
+ const commit = getSelectedInkCommit(state);
24471
+ const name = payload?.trim();
24472
+ if (!commit)
24473
+ return { ok: false, message: 'No commit selected' };
24474
+ if (!name)
24475
+ return { ok: false, message: 'Tag name required' };
24476
+ return createTagAtCommit(git, name, {
24477
+ hash: commit.hash,
24478
+ shortHash: commit.shortHash,
24479
+ });
24480
+ },
24130
24481
  'checkout-file-from-commit': async () => {
24131
24482
  // payload is "<sha> <path>" so we pass both through a single
24132
24483
  // string field on the action.
@@ -24179,6 +24530,17 @@ function LogInkApp(deps) {
24179
24530
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
24180
24531
  return { ok: false, message: 'No GitHub remote detected for this repo' };
24181
24532
  }
24533
+ // History view: prefer the cursored commit's URL so `O` from
24534
+ // a commit context lands the user on the commit page rather
24535
+ // than the repo root or the current PR. The user-visible
24536
+ // intent of `O` is "open whatever I'm cursoring on the web";
24537
+ // a commit is what the cursor is on in the history view.
24538
+ if (state.activeView === 'history') {
24539
+ const commit = getSelectedInkCommit(state);
24540
+ if (commit) {
24541
+ return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
24542
+ }
24543
+ }
24182
24544
  const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
24183
24545
  if (pr) {
24184
24546
  return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
@@ -24266,9 +24628,21 @@ function LogInkApp(deps) {
24266
24628
  }
24267
24629
  const result = await handler();
24268
24630
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
24269
- // Silent refresh so the deleted item disappears from the list without
24270
- // flickering the surfaces through a 'loading' phase.
24271
- await refreshContext({ silent: true });
24631
+ // Checkout-branch is the one workflow where we want a *visible*
24632
+ // refresh so the user sees the branches sidebar repaint with the
24633
+ // new current branch (per #806 follow-up). Snap the cursor to
24634
+ // position 0 first so when the refresh completes and the new
24635
+ // current branch lands at the top (per #809's pin-current rule),
24636
+ // the cursor is already there waiting.
24637
+ if (id === 'checkout-branch' && result?.ok) {
24638
+ dispatch({ type: 'resetBranchSelection' });
24639
+ await refreshContext();
24640
+ }
24641
+ else {
24642
+ // Silent refresh so the deleted item disappears from the list
24643
+ // without flickering the surfaces through a 'loading' phase.
24644
+ await refreshContext({ silent: true });
24645
+ }
24272
24646
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
24273
24647
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
24274
24648
  state.tagSort]);
@@ -24750,6 +25124,7 @@ function LogInkApp(deps) {
24750
25124
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
24751
25125
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
24752
25126
  sidebarFocused: state.focus === 'sidebar',
25127
+ inspectorFocused: state.focus === 'detail',
24753
25128
  });
24754
25129
  if (layout.tooSmall) {
24755
25130
  return h(Box, {
@@ -24764,7 +25139,7 @@ function LogInkApp(deps) {
24764
25139
  if (showOnboarding) {
24765
25140
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
24766
25141
  }
24767
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
25142
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
24768
25143
  }
24769
25144
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
24770
25145
  const { Box, Text } = components;
@@ -24817,7 +25192,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
24817
25192
  ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
24818
25193
  : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
24819
25194
  }
24820
- function renderSidebar(h, components, state, context, contextStatus, width, theme) {
25195
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
24821
25196
  const { Box, Text } = components;
24822
25197
  const focused = state.focus === 'sidebar';
24823
25198
  const tabs = getLogInkSidebarTabs();
@@ -24841,7 +25216,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24841
25216
  dimColor: !isActive,
24842
25217
  }, headerText));
24843
25218
  if (isActive) {
24844
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
25219
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
24845
25220
  }
24846
25221
  return blocks;
24847
25222
  });
@@ -24860,7 +25235,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24860
25235
  * surface; every other tab falls through to `sidebarLines` for its
24861
25236
  * string-based summary.
24862
25237
  */
24863
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
25238
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
25239
+ // Available rows for the active tab's list. The sidebar chrome
25240
+ // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
25241
+ // spacers); the branches tab eats 3 more for its summary header
25242
+ // (Current / Worktree / spacer). Floor of 8 keeps short terminals
25243
+ // usable; tall terminals (40+ rows) get noticeably more items.
25244
+ const sidebarChrome = 10;
25245
+ const branchHeaderRows = tab === 'branches' ? 3 : 0;
25246
+ const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
24864
25247
  if (tab === 'status') {
24865
25248
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
24866
25249
  }
@@ -24885,7 +25268,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24885
25268
  ];
24886
25269
  return [
24887
25270
  ...headerRows,
24888
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
25271
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
24889
25272
  ];
24890
25273
  }
24891
25274
  if (tab === 'tags') {
@@ -24896,7 +25279,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24896
25279
  if (tags.length === 0) {
24897
25280
  return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
24898
25281
  }
24899
- return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
25282
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
24900
25283
  }
24901
25284
  if (tab === 'stashes') {
24902
25285
  if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
@@ -24906,7 +25289,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24906
25289
  if (stashes.length === 0) {
24907
25290
  return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
24908
25291
  }
24909
- return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
25292
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
24910
25293
  }
24911
25294
  // worktrees
24912
25295
  if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
@@ -24920,7 +25303,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24920
25303
  const marker = worktree.current ? '*' : ' ';
24921
25304
  const wstate = worktree.dirty ? 'dirty' : 'clean';
24922
25305
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
24923
- }, 'tab-worktrees');
25306
+ }, 'tab-worktrees', visibleListCount);
24924
25307
  }
24925
25308
  /**
24926
25309
  * Render a sliding-window list of selectable sidebar rows. The cursor
@@ -24929,10 +25312,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24929
25312
  * Sliding window keeps the cursor in view as the user navigates a long
24930
25313
  * list; truncation hints surface the count of hidden rows.
24931
25314
  */
24932
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
25315
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
24933
25316
  if (items.length === 0)
24934
25317
  return [];
24935
- const window = getSidebarVisibleWindow(items.length, selectedIndex);
25318
+ const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
24936
25319
  const elements = [];
24937
25320
  if (window.truncatedAbove > 0) {
24938
25321
  elements.push(h(Text, {
@@ -25833,7 +26216,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
25833
26216
  }, truncate$1(line, 140)))
25834
26217
  : []));
25835
26218
  }
25836
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
26219
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
25837
26220
  const focused = state.focus === 'detail';
25838
26221
  if (state.showHelp) {
25839
26222
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -25890,16 +26273,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
25890
26273
  if (state.activeView === 'stash') {
25891
26274
  return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
25892
26275
  }
25893
- return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
26276
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
25894
26277
  }
25895
- function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
26278
+ function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
25896
26279
  const { Box, Text } = components;
25897
26280
  const selected = getSelectedInkCommit(state);
25898
- const workflowSections = getLogInkWorkflowSections({
25899
- ...context,
25900
- contextLoading: isLogInkContextLoading(contextStatus),
25901
- selectedCommit: selected,
25902
- });
25903
26281
  if (!detail) {
25904
26282
  const fallbackLines = [
25905
26283
  selected?.message || 'No commit selected.',
@@ -25915,7 +26293,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25915
26293
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
25916
26294
  key: `detail-${index}`,
25917
26295
  dimColor: index > 1,
25918
- }, truncate$1(line, width - 4))));
26296
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
25919
26297
  }
25920
26298
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
25921
26299
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -25926,18 +26304,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25926
26304
  const refNodes = detail.refs.length
25927
26305
  ? renderInspectorRefs(h, Text, detail.refs, repository)
25928
26306
  : null;
26307
+ // Inspector reorder (PR — drop duplicative Workflows trailer):
26308
+ // 1. Commit message (the headline of what you're looking at)
26309
+ // 2. Metadata (hash / author / date / refs / stats)
26310
+ // 3. Body preview (up to 8 lines now that the trailer is gone)
26311
+ // 4. Changed files list (cursored entry highlights)
26312
+ // 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
26313
+ // The Workflows: trailer that used to repeat the repo / branch /
26314
+ // status from the top header and left sidebar is intentionally gone.
25929
26315
  const headerNodes = [
25930
26316
  h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
25931
26317
  h(Text, { key: 'detail-spacer-1' }, ''),
25932
26318
  h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
25933
26319
  h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
25934
- h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
26320
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
25935
26321
  refNodes
25936
- ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
25937
- : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
25938
- h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
26322
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
26323
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
26324
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
25939
26325
  h(Text, { key: 'detail-spacer-2' }, ''),
25940
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
26326
+ ...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
25941
26327
  key: `detail-body-${index}`,
25942
26328
  dimColor: true,
25943
26329
  }, truncate$1(line, width - 4))),
@@ -25946,24 +26332,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25946
26332
  ];
25947
26333
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
25948
26334
  const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
25949
- const trailerLines = [
25950
- '',
25951
- 'Workflows:',
25952
- ...workflowSections.flatMap((section) => [
25953
- section.title,
25954
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
25955
- ]).slice(0, 12),
25956
- ];
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.
26340
+ 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
+ return h(Box, {
26350
+ borderColor: focusBorderColor(theme, focused),
26351
+ borderStyle: theme.borderStyle,
26352
+ flexDirection: 'column',
26353
+ width,
26354
+ paddingX: 1,
26355
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26356
+ ? [...headerNodes, ...fileListNodes]
26357
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26358
+ }
25957
26359
  return h(Box, {
25958
26360
  borderColor: focusBorderColor(theme, focused),
25959
26361
  borderStyle: theme.borderStyle,
25960
26362
  flexDirection: 'column',
25961
26363
  width,
25962
26364
  paddingX: 1,
25963
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
25964
- key: `detail-trailer-${index}`,
25965
- dimColor: index > 0,
25966
- }, truncate$1(line, width - 4))));
26365
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26366
+ }
26367
+ /**
26368
+ * Render the trailing "Actions:" section that surfaces which keystrokes
26369
+ * apply to whatever the inspector is focused on. Keys are colored with
26370
+ * `theme.colors.accent` so they pop as the actionable element. Destructive
26371
+ * actions get the danger color plus a `[!]` marker so they don't blend
26372
+ * into the cherry-pick / yank rows.
26373
+ *
26374
+ * Truncates labels when the inspector is narrow (down to the 26-cell
26375
+ * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26376
+ * collides with the next row.
26377
+ */
26378
+ function renderInspectorActionsSection(h, Text, context, width, theme) {
26379
+ const actions = getInspectorActions(context);
26380
+ if (!actions.length)
26381
+ return [];
26382
+ // Width budget for each row: subtract padding + " " gutter, the key
26383
+ // column (left-padded to 5 cells so labels align), the " " gap
26384
+ // between key and label, and the optional " [!]" suffix (5 cells).
26385
+ const KEY_COLUMN = 5;
26386
+ const GAP = ' ';
26387
+ const DESTRUCTIVE_SUFFIX = ' [!]';
26388
+ const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26389
+ const nodes = [
26390
+ h(Text, { key: 'actions-spacer' }, ''),
26391
+ h(Text, { key: 'actions-title' }, 'Actions:'),
26392
+ ...actions.map((action, index) => {
26393
+ const keyCell = action.key.padEnd(KEY_COLUMN);
26394
+ const label = truncate$1(action.label, labelBudget);
26395
+ const children = [
26396
+ h(Text, {
26397
+ key: `actions-${index}-key`,
26398
+ color: action.destructive ? theme.colors.danger : theme.colors.accent,
26399
+ }, keyCell),
26400
+ GAP,
26401
+ label,
26402
+ ];
26403
+ if (action.destructive) {
26404
+ children.push(h(Text, {
26405
+ key: `actions-${index}-mark`,
26406
+ color: theme.colors.danger,
26407
+ dimColor: false,
26408
+ }, DESTRUCTIVE_SUFFIX));
26409
+ }
26410
+ return h(Text, { key: `actions-${index}` }, ...children);
26411
+ }),
26412
+ ];
26413
+ return nodes;
25967
26414
  }
25968
26415
  /**
25969
26416
  * Build a commit URL for the repo when GitHub provider info is available.
@@ -26537,7 +26984,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
26537
26984
  const git = options.git || getRepo();
26538
26985
  const rows = options.rows || (await getLogRows(git, logArgv));
26539
26986
  await startInkInteractiveLog(git, rows, {}, {
26540
- appLabel: 'coco ui',
26987
+ appLabel: 'coco',
26541
26988
  idleTips: config.logTui?.idleTips,
26542
26989
  initialView: 'history',
26543
26990
  logArgv,
@@ -26550,7 +26997,7 @@ async function startCocoUi(argv) {
26550
26997
  const logArgv = createLogArgvFromUiArgv(argv);
26551
26998
  const rows = await getLogRows(git, logArgv);
26552
26999
  await startInkInteractiveLog(git, rows, {}, {
26553
- appLabel: 'coco ui',
27000
+ appLabel: 'coco',
26554
27001
  idleTips: config.logTui?.idleTips,
26555
27002
  initialView: argv.view || 'history',
26556
27003
  logArgv,