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
@@ -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.38.0";
56
+ const BUILD_VERSION = "0.39.0";
57
57
 
58
58
  const isInteractive = (config) => {
59
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14747,84 +14747,6 @@ function formatInkRefLabels(refs) {
14747
14747
  return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
14748
14748
  }
14749
14749
 
14750
- function countLabel(count, singular, plural = `${singular}s`) {
14751
- return `${count} ${count === 1 ? singular : plural}`;
14752
- }
14753
- function getLogInkWorkflowSections(context) {
14754
- const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
14755
- const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
14756
- const loading = context.contextLoading;
14757
- const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
14758
- const repository = context.provider?.repository;
14759
- const repoName = repository?.owner && repository.name
14760
- ? `${repository.owner}/${repository.name}`
14761
- : repository?.message || 'local repository';
14762
- const operation = context.operation;
14763
- const worktree = context.worktree;
14764
- return [
14765
- {
14766
- title: 'Branch',
14767
- lines: [
14768
- `Current: ${currentBranch}`,
14769
- `State: ${dirty}`,
14770
- loading && !context.branches
14771
- ? 'Branch data loading'
14772
- : context.branches
14773
- ? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
14774
- : 'Branch data unavailable',
14775
- ],
14776
- },
14777
- {
14778
- title: 'Provider / PR',
14779
- lines: [
14780
- `Repository: ${repoName}`,
14781
- loading && !context.provider && !context.pullRequest
14782
- ? 'Provider and pull request data loading'
14783
- : currentPullRequest
14784
- ? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
14785
- : 'No pull request detected for current branch',
14786
- context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
14787
- ],
14788
- },
14789
- {
14790
- title: 'Status',
14791
- lines: loading && !worktree
14792
- ? ['Status data loading']
14793
- : worktree
14794
- ? [
14795
- `${countLabel(worktree.stagedCount, 'staged file')}`,
14796
- `${countLabel(worktree.unstagedCount, 'unstaged file')}`,
14797
- `${countLabel(worktree.untrackedCount, 'untracked file')}`,
14798
- ]
14799
- : ['Status data unavailable'],
14800
- },
14801
- {
14802
- title: 'Tags / Stashes / Worktrees',
14803
- lines: [
14804
- loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
14805
- loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
14806
- context.worktreeList
14807
- ? countLabel(context.worktreeList.worktrees.length, 'worktree')
14808
- : loading
14809
- ? 'Worktrees loading'
14810
- : 'Worktrees unavailable',
14811
- ],
14812
- },
14813
- {
14814
- title: 'Operation / AI',
14815
- lines: [
14816
- loading && !operation
14817
- ? 'Operation data loading'
14818
- : operation?.operation
14819
- ? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
14820
- : 'No merge, rebase, cherry-pick, or revert in progress',
14821
- context.selectedCommit
14822
- ? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
14823
- : 'AI actions require a selected commit',
14824
- ],
14825
- },
14826
- ];
14827
- }
14828
14750
  function getLogInkWorkflowActions() {
14829
14751
  return [
14830
14752
  {
@@ -15068,6 +14990,38 @@ function getLogInkWorkflowActions() {
15068
14990
  kind: 'destructive',
15069
14991
  requiresConfirmation: true,
15070
14992
  },
14993
+ {
14994
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
14995
+ // The prompt itself is the affirmative gate — the user has to
14996
+ // type a branch name before anything happens — so this skips the
14997
+ // y-confirm path. Empty key keeps it palette-discoverable; the
14998
+ // palette path can't synthesize a branch name and surfaces a
14999
+ // hint instead.
15000
+ //
15001
+ // Distinct from `create-branch` (palette / `+` on branches view),
15002
+ // which uses `git switch -c` and switches onto the new branch.
15003
+ // This workflow uses `git branch <name> <sha>` and stays put —
15004
+ // GitKraken's "create branch here" semantic.
15005
+ id: 'create-branch-here',
15006
+ key: '',
15007
+ label: 'Create branch from commit',
15008
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15009
+ kind: 'normal',
15010
+ requiresConfirmation: false,
15011
+ },
15012
+ {
15013
+ // Per-view-only: scoped to the history view in inkInput via the
15014
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15015
+ // Same prompt-as-confirmation pattern as create-branch-here.
15016
+ // Lightweight tag — annotated tags remain available through the
15017
+ // existing `+` flow on the tags view.
15018
+ id: 'create-tag-here',
15019
+ key: '',
15020
+ label: 'Create tag at commit',
15021
+ description: 'Create a lightweight tag at the cursored commit.',
15022
+ kind: 'normal',
15023
+ requiresConfirmation: false,
15024
+ },
15071
15025
  {
15072
15026
  // Per-view-only: scoped to the history view in inkInput. `i`
15073
15027
  // (lowercase) is used instead of `I` so the existing `I`
@@ -15080,6 +15034,38 @@ function getLogInkWorkflowActions() {
15080
15034
  kind: 'destructive',
15081
15035
  requiresConfirmation: true,
15082
15036
  },
15037
+ {
15038
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15039
+ // The prompt itself is the affirmative gate — the user has to
15040
+ // type a branch name before anything happens — so this skips the
15041
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15042
+ // palette path can't synthesize a branch name and surfaces a
15043
+ // hint instead.
15044
+ //
15045
+ // Distinct from `create-branch` (palette / `+` on branches view),
15046
+ // which uses `git switch -c` and switches onto the new branch.
15047
+ // This workflow uses `git branch <name> <sha>` and stays put —
15048
+ // GitKraken's "create branch here" semantic.
15049
+ id: 'create-branch-here',
15050
+ key: '',
15051
+ label: 'Create branch from commit',
15052
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15053
+ kind: 'normal',
15054
+ requiresConfirmation: false,
15055
+ },
15056
+ {
15057
+ // Per-view-only: scoped to the history view in inkInput via the
15058
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15059
+ // Same prompt-as-confirmation pattern as create-branch-here.
15060
+ // Lightweight tag — annotated tags remain available through the
15061
+ // existing `+` flow on the tags view.
15062
+ id: 'create-tag-here',
15063
+ key: '',
15064
+ label: 'Create tag at commit',
15065
+ description: 'Create a lightweight tag at the cursored commit.',
15066
+ kind: 'normal',
15067
+ requiresConfirmation: false,
15068
+ },
15083
15069
  {
15084
15070
  id: 'ai-commit-summary',
15085
15071
  key: 'I',
@@ -15614,9 +15600,12 @@ function getLogInkFooterHints(options) {
15614
15600
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15615
15601
  // revert, `Z` reset, `i` interactive-rebase) all route through a
15616
15602
  // y-confirm or mode prompt — none fire silently from the keystroke.
15617
- // Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
15618
- // scannable; full descriptions live in `?` help and the palette.
15619
- contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15603
+ // `B` create-branch-here and `gT` create-tag-here use a prompt as
15604
+ // the affirmative gate (typing the name is the confirmation).
15605
+ // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
15606
+ // the footer stays scannable; full descriptions live in `?` help
15607
+ // and the palette.
15608
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15620
15609
  global: NORMAL_GLOBAL_HINTS,
15621
15610
  };
15622
15611
  }
@@ -15927,23 +15916,31 @@ function cycleBranchSort(mode) {
15927
15916
  return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
15928
15917
  }
15929
15918
  function sortBranches(branches, mode) {
15930
- const copy = branches.slice();
15919
+ // Pin the current branch at index 0 regardless of sort mode (#806
15920
+ // follow-up). Lands the user's cursor on the active branch by
15921
+ // default and keeps the most-relevant row glued to the top of the
15922
+ // list as they cycle sorts.
15923
+ const current = branches.find((entry) => entry.current);
15924
+ const rest = branches.filter((entry) => !entry.current);
15925
+ const sortedRest = rest.slice();
15931
15926
  switch (mode) {
15932
15927
  case 'name':
15933
- return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
15928
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
15929
+ break;
15934
15930
  case 'recent':
15935
15931
  // ISO-shaped dates compare byte-for-byte; descending so the freshest
15936
15932
  // branch sits at the top.
15937
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15933
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15938
15934
  a.shortName.localeCompare(b.shortName));
15935
+ break;
15939
15936
  case 'ahead':
15940
15937
  // ahead-first; ties broken by behind, then by name. Keeps "this branch
15941
15938
  // has unmerged work" in the user's first scroll.
15942
- return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15939
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15943
15940
  a.shortName.localeCompare(b.shortName));
15944
- default:
15945
- return copy;
15941
+ break;
15946
15942
  }
15943
+ return current ? [current, ...sortedRest] : sortedRest;
15947
15944
  }
15948
15945
  const TAG_SORT_MODES = ['recent', 'name'];
15949
15946
  const DEFAULT_TAG_SORT_MODE = 'recent';
@@ -16278,6 +16275,7 @@ function createLogInkState(rows, options = {}) {
16278
16275
  userSidebarTab: 'status',
16279
16276
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16280
16277
  diffViewMode: 'unified',
16278
+ inspectorTab: 'inspector',
16281
16279
  };
16282
16280
  }
16283
16281
  function getSelectedInkCommit(state) {
@@ -16335,6 +16333,28 @@ function applyLogInkAction(state, action) {
16335
16333
  pendingCommitFocused: false,
16336
16334
  pendingKey: undefined,
16337
16335
  };
16336
+ case 'selectCommitByHash': {
16337
+ // Locates a commit by its full or short hash within the active
16338
+ // filtered list and snaps the cursor to it. Used by the
16339
+ // branch/tag auto-jump effect (#806 follow-up): cursoring a
16340
+ // branch in the sidebar tracks the history view to that
16341
+ // branch's tip without the user manually scrolling. No-op when
16342
+ // the hash isn't in the loaded list (the runtime surfaces a
16343
+ // status hint in that case).
16344
+ const target = action.hash;
16345
+ const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
16346
+ if (index < 0) {
16347
+ return state;
16348
+ }
16349
+ return {
16350
+ ...state,
16351
+ selectedIndex: index,
16352
+ selectedFileIndex: 0,
16353
+ diffPreviewOffset: 0,
16354
+ pendingCommitFocused: false,
16355
+ pendingKey: undefined,
16356
+ };
16357
+ }
16338
16358
  case 'focusPendingCommit':
16339
16359
  return {
16340
16360
  ...state,
@@ -16374,6 +16394,34 @@ function applyLogInkAction(state, action) {
16374
16394
  selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
16375
16395
  pendingKey: undefined,
16376
16396
  };
16397
+ case 'resetBranchSelection':
16398
+ // Snap the branches sidebar / view cursor back to position 0.
16399
+ // Used after a successful checkout (#806 follow-up): combined
16400
+ // with the "current branch pinned at top" rule from #809, this
16401
+ // lands the user's cursor on the just-checked-out branch.
16402
+ return {
16403
+ ...state,
16404
+ selectedBranchIndex: 0,
16405
+ pendingKey: undefined,
16406
+ };
16407
+ case 'setInspectorTab':
16408
+ return {
16409
+ ...state,
16410
+ inspectorTab: action.value,
16411
+ pendingKey: undefined,
16412
+ };
16413
+ case 'cycleInspectorTab': {
16414
+ // Two-tab toggle — `delta` is symmetrical so direction does not
16415
+ // matter, but we keep the action shape consistent with the
16416
+ // sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
16417
+ // can mirror the sidebar pattern verbatim.
16418
+ const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
16419
+ return {
16420
+ ...state,
16421
+ inspectorTab: next,
16422
+ pendingKey: undefined,
16423
+ };
16424
+ }
16377
16425
  case 'moveTag':
16378
16426
  return {
16379
16427
  ...state,
@@ -17459,6 +17507,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17459
17507
  action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
17460
17508
  ];
17461
17509
  }
17510
+ // `gT` chord: create a lightweight tag at the cursored commit on the
17511
+ // history view. Bare `T` is taken (delete-tag on the tags view) so we
17512
+ // use the chord. Mirrors `gH` exactly — uppercase letter after the
17513
+ // `g` chord prefix, distinct from the lowercase `gt` chord which
17514
+ // jumps to the tags view. The prompt is the affirmative gate.
17515
+ if (state.pendingKey === 'g' && inputValue === 'T') {
17516
+ if (state.activeView === 'history' &&
17517
+ state.focus === 'commits' &&
17518
+ state.filteredCommits.length > 0 &&
17519
+ !state.pendingCommitFocused) {
17520
+ return [
17521
+ action({ type: 'setPendingKey', value: undefined }),
17522
+ action({
17523
+ type: 'openInputPrompt',
17524
+ kind: 'create-tag-here',
17525
+ label: 'New tag name (at cursored commit)',
17526
+ }),
17527
+ ];
17528
+ }
17529
+ return [
17530
+ action({ type: 'setPendingKey', value: undefined }),
17531
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
17532
+ ];
17533
+ }
17462
17534
  if (inputValue === 'g') {
17463
17535
  if (state.pendingKey === 'g') {
17464
17536
  return [
@@ -17540,6 +17612,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17540
17612
  hunkOffsets: context.commitDiffHunkOffsets,
17541
17613
  })];
17542
17614
  }
17615
+ // Inspector focused: cycle the inspector tab. The renderer only
17616
+ // honors the tab field on short terminals (where the inspector
17617
+ // collapses into a tabbed layout), but we let the user pre-set
17618
+ // their preference on tall terminals too.
17619
+ if (state.focus === 'detail') {
17620
+ return [action({ type: 'cycleInspectorTab', delta: -1 })];
17621
+ }
17543
17622
  return [action({ type: 'previousSidebarTab' })];
17544
17623
  }
17545
17624
  if (inputValue === ']') {
@@ -17564,6 +17643,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17564
17643
  hunkOffsets: context.commitDiffHunkOffsets,
17565
17644
  })];
17566
17645
  }
17646
+ if (state.focus === 'detail') {
17647
+ return [action({ type: 'cycleInspectorTab', delta: 1 })];
17648
+ }
17567
17649
  return [action({ type: 'nextSidebarTab' })];
17568
17650
  }
17569
17651
  // Status surface intercepts 1/2/3 before the sidebar-tab numeric
@@ -18063,6 +18145,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18063
18145
  !state.pendingCommitFocused) {
18064
18146
  return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18065
18147
  }
18148
+ // `B` opens a create-branch prompt rooted at the cursored commit
18149
+ // (`git branch <name> <sha>` — does NOT switch to the new branch).
18150
+ // The prompt itself is the affirmative gate, so no separate y-confirm.
18151
+ // Bare uppercase `B` since the lowercase `b` is used by the `gb`
18152
+ // chord prefix and we want a single keystroke for this common op.
18153
+ if (inputValue === 'B' &&
18154
+ state.activeView === 'history' &&
18155
+ state.focus === 'commits' &&
18156
+ state.filteredCommits.length > 0 &&
18157
+ !state.pendingCommitFocused) {
18158
+ return [action({
18159
+ type: 'openInputPrompt',
18160
+ kind: 'create-branch-here',
18161
+ label: 'New branch name (at cursored commit)',
18162
+ })];
18163
+ }
18066
18164
  // `y` / `Y` yank the contextually relevant identifier from the active
18067
18165
  // view to the system clipboard:
18068
18166
  // history → cursored commit hash (Y for short hash)
@@ -18689,10 +18787,25 @@ const LOG_INK_MIN_COLUMNS = 80;
18689
18787
  const LOG_INK_MIN_ROWS = 24;
18690
18788
  const LOG_INK_DEFAULT_COLUMNS = 120;
18691
18789
  const LOG_INK_DEFAULT_ROWS = 40;
18790
+ /**
18791
+ * Terminal-row threshold below which the inspector switches to a
18792
+ * tabbed layout (commit-detail vs actions). Picked empirically: at
18793
+ * 28 rows the inspector's full stack (~30 rows when fully populated)
18794
+ * starts clipping the actions section; below that, the tabbed mode
18795
+ * gives both views their own air.
18796
+ */
18797
+ const INSPECTOR_TABBED_BELOW_ROWS = 28;
18692
18798
  function getLogInkLayout(input) {
18693
18799
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
18694
18800
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
18695
- const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
18801
+ // Inspector width at rest 20-32 cells (~22% of width), focused
18802
+ // 36-60 cells (~40% of width). Narrow rest state keeps the commit
18803
+ // graph dominant; focus expansion gives the inspector room for long
18804
+ // commit bodies / file lists / action labels. Mirrors the sidebar
18805
+ // pattern (sidebarFocused above): instant transition per render.
18806
+ const detailWidth = input.inspectorFocused
18807
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
18808
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
18696
18809
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
18697
18810
  // (~36% of width). The transition is instant per render — focus tab to
18698
18811
  // expand, focus away to collapse.
@@ -18707,6 +18820,7 @@ function getLogInkLayout(input) {
18707
18820
  rows,
18708
18821
  sidebarWidth,
18709
18822
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
18823
+ inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
18710
18824
  };
18711
18825
  }
18712
18826
 
@@ -20202,6 +20316,63 @@ function resetToCommit(git, commit, mode) {
20202
20316
  : result.details,
20203
20317
  }));
20204
20318
  }
20319
+ /**
20320
+ * Create a new local branch pointed at <commit>, without switching to it.
20321
+ *
20322
+ * This is the "create branch from cursored commit" history action — the
20323
+ * user types the new branch name into an input prompt and we run
20324
+ * `git branch <name> <sha>` (NOT `git switch -c`, which is what
20325
+ * `branchActions.createBranch` does for the create-branch-at-HEAD flow).
20326
+ * The split exists because GitKraken-style "create branch here" is
20327
+ * specifically about marking a historical commit, not about switching
20328
+ * onto a new working branch.
20329
+ *
20330
+ * Note for the inspector follow-up: workflow surfacing is driven by the
20331
+ * registry in `inkWorkflows.ts`, not a hardcoded action list — adding
20332
+ * `create-branch-here` there is enough for the inspector / palette to
20333
+ * pick this up.
20334
+ */
20335
+ function createBranchFromCommit(git, name, commit) {
20336
+ const trimmedName = name.trim();
20337
+ if (!commit) {
20338
+ return Promise.resolve({
20339
+ ok: false,
20340
+ message: 'No commit selected.',
20341
+ });
20342
+ }
20343
+ if (!trimmedName) {
20344
+ return Promise.resolve({
20345
+ ok: false,
20346
+ message: 'Branch name required.',
20347
+ });
20348
+ }
20349
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
20350
+ }
20351
+ /**
20352
+ * Create a lightweight tag pointed at <commit>.
20353
+ *
20354
+ * Mirrors `createBranchFromCommit` for the tag side: the user types a
20355
+ * tag name into an input prompt and we run `git tag <name> <sha>`
20356
+ * (lightweight, no `-a`/`-m`). Annotated tags remain available through
20357
+ * the existing `+` flow on the tags view; this is the per-commit
20358
+ * shortcut.
20359
+ */
20360
+ function createTagAtCommit(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: 'Tag name required.',
20372
+ });
20373
+ }
20374
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
20375
+ }
20205
20376
  function startInteractiveRebase(git, commit) {
20206
20377
  if (!commit) {
20207
20378
  return Promise.resolve({
@@ -22928,6 +23099,88 @@ function formatPullRequestStateLine(pr) {
22928
23099
  return parts.join(' · ');
22929
23100
  }
22930
23101
 
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
+
22931
23184
  function sectionLines(title, diff) {
22932
23185
  const lines = diff.split('\n').map((line) => line.trimEnd());
22933
23186
  return [
@@ -23727,6 +23980,80 @@ function LogInkApp(deps) {
23727
23980
  active = false;
23728
23981
  };
23729
23982
  }, [git, selected?.hash]);
23983
+ // #806 follow-up — auto-jump the history view to whichever branch /
23984
+ // tag the user is currently cursoring in the sidebar (or the
23985
+ // dedicated branches / tags view). Debounced so cursor-scrolling
23986
+ // through a long branch list doesn't dispatch on every keystroke.
23987
+ // No-op when the cursored ref's tip isn't in the loaded commit
23988
+ // window (under compact mode the cursored branch's tip may not be
23989
+ // fetched yet); a status hint surfaces in that case so the user
23990
+ // knows to toggle full graph or load older commits.
23991
+ React.useEffect(() => {
23992
+ const onBranchTab = state.activeView === 'branches' ||
23993
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
23994
+ const onTagTab = state.activeView === 'tags' ||
23995
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
23996
+ if (!onBranchTab && !onTagTab)
23997
+ return;
23998
+ let cancelled = false;
23999
+ const timer = setTimeout(() => {
24000
+ if (cancelled)
24001
+ return;
24002
+ let targetHash;
24003
+ let targetLabel;
24004
+ if (onBranchTab) {
24005
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24006
+ const visible = state.filter
24007
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24008
+ : all;
24009
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24010
+ if (branch) {
24011
+ targetHash = branch.hash;
24012
+ targetLabel = `branch ${branch.shortName}`;
24013
+ }
24014
+ }
24015
+ else if (onTagTab) {
24016
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24017
+ const visible = state.filter
24018
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24019
+ : all;
24020
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24021
+ if (tag) {
24022
+ targetHash = tag.hash;
24023
+ targetLabel = `tag ${tag.name}`;
24024
+ }
24025
+ }
24026
+ if (!targetHash)
24027
+ return;
24028
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24029
+ if (loaded) {
24030
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24031
+ // Confirmation status message so the user gets feedback even
24032
+ // when the dedicated branches / tags view is occupying the
24033
+ // main panel and the history cursor moves invisibly behind it.
24034
+ dispatch({
24035
+ type: 'setStatus',
24036
+ value: `Synced history to ${targetLabel} tip`,
24037
+ });
24038
+ }
24039
+ else {
24040
+ dispatch({
24041
+ type: 'setStatus',
24042
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24043
+ });
24044
+ }
24045
+ }, 150);
24046
+ return () => {
24047
+ cancelled = true;
24048
+ clearTimeout(timer);
24049
+ };
24050
+ }, [
24051
+ dispatch, context.branches, context.tags,
24052
+ state.activeView, state.focus, state.sidebarTab,
24053
+ state.selectedBranchIndex, state.selectedTagIndex,
24054
+ state.branchSort, state.tagSort, state.filter,
24055
+ state.filteredCommits,
24056
+ ]);
23730
24057
  React.useEffect(() => {
23731
24058
  let active = true;
23732
24059
  async function loadWorktreeDiff() {
@@ -24102,6 +24429,30 @@ function LogInkApp(deps) {
24102
24429
  message: commit.message,
24103
24430
  });
24104
24431
  },
24432
+ 'create-branch-here': async () => {
24433
+ const commit = getSelectedInkCommit(state);
24434
+ const name = payload?.trim();
24435
+ if (!commit)
24436
+ return { ok: false, message: 'No commit selected' };
24437
+ if (!name)
24438
+ return { ok: false, message: 'Branch name required' };
24439
+ return createBranchFromCommit(git, name, {
24440
+ hash: commit.hash,
24441
+ shortHash: commit.shortHash,
24442
+ });
24443
+ },
24444
+ 'create-tag-here': async () => {
24445
+ const commit = getSelectedInkCommit(state);
24446
+ const name = payload?.trim();
24447
+ if (!commit)
24448
+ return { ok: false, message: 'No commit selected' };
24449
+ if (!name)
24450
+ return { ok: false, message: 'Tag name required' };
24451
+ return createTagAtCommit(git, name, {
24452
+ hash: commit.hash,
24453
+ shortHash: commit.shortHash,
24454
+ });
24455
+ },
24105
24456
  'checkout-file-from-commit': async () => {
24106
24457
  // payload is "<sha> <path>" so we pass both through a single
24107
24458
  // string field on the action.
@@ -24154,6 +24505,17 @@ function LogInkApp(deps) {
24154
24505
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
24155
24506
  return { ok: false, message: 'No GitHub remote detected for this repo' };
24156
24507
  }
24508
+ // History view: prefer the cursored commit's URL so `O` from
24509
+ // a commit context lands the user on the commit page rather
24510
+ // than the repo root or the current PR. The user-visible
24511
+ // intent of `O` is "open whatever I'm cursoring on the web";
24512
+ // a commit is what the cursor is on in the history view.
24513
+ if (state.activeView === 'history') {
24514
+ const commit = getSelectedInkCommit(state);
24515
+ if (commit) {
24516
+ return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
24517
+ }
24518
+ }
24157
24519
  const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
24158
24520
  if (pr) {
24159
24521
  return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
@@ -24241,9 +24603,21 @@ function LogInkApp(deps) {
24241
24603
  }
24242
24604
  const result = await handler();
24243
24605
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
24244
- // Silent refresh so the deleted item disappears from the list without
24245
- // flickering the surfaces through a 'loading' phase.
24246
- await refreshContext({ silent: true });
24606
+ // Checkout-branch is the one workflow where we want a *visible*
24607
+ // refresh so the user sees the branches sidebar repaint with the
24608
+ // new current branch (per #806 follow-up). Snap the cursor to
24609
+ // position 0 first so when the refresh completes and the new
24610
+ // current branch lands at the top (per #809's pin-current rule),
24611
+ // the cursor is already there waiting.
24612
+ if (id === 'checkout-branch' && result?.ok) {
24613
+ dispatch({ type: 'resetBranchSelection' });
24614
+ await refreshContext();
24615
+ }
24616
+ else {
24617
+ // Silent refresh so the deleted item disappears from the list
24618
+ // without flickering the surfaces through a 'loading' phase.
24619
+ await refreshContext({ silent: true });
24620
+ }
24247
24621
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
24248
24622
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
24249
24623
  state.tagSort]);
@@ -24725,6 +25099,7 @@ function LogInkApp(deps) {
24725
25099
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
24726
25100
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
24727
25101
  sidebarFocused: state.focus === 'sidebar',
25102
+ inspectorFocused: state.focus === 'detail',
24728
25103
  });
24729
25104
  if (layout.tooSmall) {
24730
25105
  return h(Box, {
@@ -24739,7 +25114,7 @@ function LogInkApp(deps) {
24739
25114
  if (showOnboarding) {
24740
25115
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
24741
25116
  }
24742
- 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));
25117
+ 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));
24743
25118
  }
24744
25119
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
24745
25120
  const { Box, Text } = components;
@@ -24792,7 +25167,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
24792
25167
  ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
24793
25168
  : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
24794
25169
  }
24795
- function renderSidebar(h, components, state, context, contextStatus, width, theme) {
25170
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
24796
25171
  const { Box, Text } = components;
24797
25172
  const focused = state.focus === 'sidebar';
24798
25173
  const tabs = getLogInkSidebarTabs();
@@ -24816,7 +25191,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24816
25191
  dimColor: !isActive,
24817
25192
  }, headerText));
24818
25193
  if (isActive) {
24819
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
25194
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
24820
25195
  }
24821
25196
  return blocks;
24822
25197
  });
@@ -24835,7 +25210,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24835
25210
  * surface; every other tab falls through to `sidebarLines` for its
24836
25211
  * string-based summary.
24837
25212
  */
24838
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
25213
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
25214
+ // Available rows for the active tab's list. The sidebar chrome
25215
+ // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
25216
+ // spacers); the branches tab eats 3 more for its summary header
25217
+ // (Current / Worktree / spacer). Floor of 8 keeps short terminals
25218
+ // usable; tall terminals (40+ rows) get noticeably more items.
25219
+ const sidebarChrome = 10;
25220
+ const branchHeaderRows = tab === 'branches' ? 3 : 0;
25221
+ const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
24839
25222
  if (tab === 'status') {
24840
25223
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
24841
25224
  }
@@ -24860,7 +25243,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24860
25243
  ];
24861
25244
  return [
24862
25245
  ...headerRows,
24863
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
25246
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
24864
25247
  ];
24865
25248
  }
24866
25249
  if (tab === 'tags') {
@@ -24871,7 +25254,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24871
25254
  if (tags.length === 0) {
24872
25255
  return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
24873
25256
  }
24874
- return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
25257
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
24875
25258
  }
24876
25259
  if (tab === 'stashes') {
24877
25260
  if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
@@ -24881,7 +25264,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24881
25264
  if (stashes.length === 0) {
24882
25265
  return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
24883
25266
  }
24884
- return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
25267
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
24885
25268
  }
24886
25269
  // worktrees
24887
25270
  if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
@@ -24895,7 +25278,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24895
25278
  const marker = worktree.current ? '*' : ' ';
24896
25279
  const wstate = worktree.dirty ? 'dirty' : 'clean';
24897
25280
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
24898
- }, 'tab-worktrees');
25281
+ }, 'tab-worktrees', visibleListCount);
24899
25282
  }
24900
25283
  /**
24901
25284
  * Render a sliding-window list of selectable sidebar rows. The cursor
@@ -24904,10 +25287,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24904
25287
  * Sliding window keeps the cursor in view as the user navigates a long
24905
25288
  * list; truncation hints surface the count of hidden rows.
24906
25289
  */
24907
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
25290
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
24908
25291
  if (items.length === 0)
24909
25292
  return [];
24910
- const window = getSidebarVisibleWindow(items.length, selectedIndex);
25293
+ const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
24911
25294
  const elements = [];
24912
25295
  if (window.truncatedAbove > 0) {
24913
25296
  elements.push(h(Text, {
@@ -25808,7 +26191,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
25808
26191
  }, truncate$1(line, 140)))
25809
26192
  : []));
25810
26193
  }
25811
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
26194
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
25812
26195
  const focused = state.focus === 'detail';
25813
26196
  if (state.showHelp) {
25814
26197
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -25865,16 +26248,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
25865
26248
  if (state.activeView === 'stash') {
25866
26249
  return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
25867
26250
  }
25868
- return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
26251
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
25869
26252
  }
25870
- function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
26253
+ function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
25871
26254
  const { Box, Text } = components;
25872
26255
  const selected = getSelectedInkCommit(state);
25873
- const workflowSections = getLogInkWorkflowSections({
25874
- ...context,
25875
- contextLoading: isLogInkContextLoading(contextStatus),
25876
- selectedCommit: selected,
25877
- });
25878
26256
  if (!detail) {
25879
26257
  const fallbackLines = [
25880
26258
  selected?.message || 'No commit selected.',
@@ -25890,7 +26268,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25890
26268
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
25891
26269
  key: `detail-${index}`,
25892
26270
  dimColor: index > 1,
25893
- }, truncate$1(line, width - 4))));
26271
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
25894
26272
  }
25895
26273
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
25896
26274
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -25901,18 +26279,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25901
26279
  const refNodes = detail.refs.length
25902
26280
  ? renderInspectorRefs(h, Text, detail.refs, repository)
25903
26281
  : null;
26282
+ // Inspector reorder (PR — drop duplicative Workflows trailer):
26283
+ // 1. Commit message (the headline of what you're looking at)
26284
+ // 2. Metadata (hash / author / date / refs / stats)
26285
+ // 3. Body preview (up to 8 lines now that the trailer is gone)
26286
+ // 4. Changed files list (cursored entry highlights)
26287
+ // 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
26288
+ // The Workflows: trailer that used to repeat the repo / branch /
26289
+ // status from the top header and left sidebar is intentionally gone.
25904
26290
  const headerNodes = [
25905
26291
  h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
25906
26292
  h(Text, { key: 'detail-spacer-1' }, ''),
25907
26293
  h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
25908
26294
  h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
25909
- h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
26295
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
25910
26296
  refNodes
25911
- ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
25912
- : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
25913
- h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
26297
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
26298
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
26299
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
25914
26300
  h(Text, { key: 'detail-spacer-2' }, ''),
25915
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
26301
+ ...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
25916
26302
  key: `detail-body-${index}`,
25917
26303
  dimColor: true,
25918
26304
  }, truncate$1(line, width - 4))),
@@ -25921,24 +26307,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25921
26307
  ];
25922
26308
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
25923
26309
  const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
25924
- const trailerLines = [
25925
- '',
25926
- 'Workflows:',
25927
- ...workflowSections.flatMap((section) => [
25928
- section.title,
25929
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
25930
- ]).slice(0, 12),
25931
- ];
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.
26315
+ 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
+ return h(Box, {
26325
+ borderColor: focusBorderColor(theme, focused),
26326
+ borderStyle: theme.borderStyle,
26327
+ flexDirection: 'column',
26328
+ width,
26329
+ paddingX: 1,
26330
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26331
+ ? [...headerNodes, ...fileListNodes]
26332
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26333
+ }
25932
26334
  return h(Box, {
25933
26335
  borderColor: focusBorderColor(theme, focused),
25934
26336
  borderStyle: theme.borderStyle,
25935
26337
  flexDirection: 'column',
25936
26338
  width,
25937
26339
  paddingX: 1,
25938
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
25939
- key: `detail-trailer-${index}`,
25940
- dimColor: index > 0,
25941
- }, truncate$1(line, width - 4))));
26340
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26341
+ }
26342
+ /**
26343
+ * Render the trailing "Actions:" section that surfaces which keystrokes
26344
+ * apply to whatever the inspector is focused on. Keys are colored with
26345
+ * `theme.colors.accent` so they pop as the actionable element. Destructive
26346
+ * actions get the danger color plus a `[!]` marker so they don't blend
26347
+ * into the cherry-pick / yank rows.
26348
+ *
26349
+ * Truncates labels when the inspector is narrow (down to the 26-cell
26350
+ * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26351
+ * collides with the next row.
26352
+ */
26353
+ function renderInspectorActionsSection(h, Text, context, width, theme) {
26354
+ const actions = getInspectorActions(context);
26355
+ if (!actions.length)
26356
+ return [];
26357
+ // Width budget for each row: subtract padding + " " gutter, the key
26358
+ // column (left-padded to 5 cells so labels align), the " " gap
26359
+ // between key and label, and the optional " [!]" suffix (5 cells).
26360
+ const KEY_COLUMN = 5;
26361
+ const GAP = ' ';
26362
+ const DESTRUCTIVE_SUFFIX = ' [!]';
26363
+ const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26364
+ const nodes = [
26365
+ h(Text, { key: 'actions-spacer' }, ''),
26366
+ h(Text, { key: 'actions-title' }, 'Actions:'),
26367
+ ...actions.map((action, index) => {
26368
+ const keyCell = action.key.padEnd(KEY_COLUMN);
26369
+ const label = truncate$1(action.label, labelBudget);
26370
+ const children = [
26371
+ h(Text, {
26372
+ key: `actions-${index}-key`,
26373
+ color: action.destructive ? theme.colors.danger : theme.colors.accent,
26374
+ }, keyCell),
26375
+ GAP,
26376
+ label,
26377
+ ];
26378
+ if (action.destructive) {
26379
+ children.push(h(Text, {
26380
+ key: `actions-${index}-mark`,
26381
+ color: theme.colors.danger,
26382
+ dimColor: false,
26383
+ }, DESTRUCTIVE_SUFFIX));
26384
+ }
26385
+ return h(Text, { key: `actions-${index}` }, ...children);
26386
+ }),
26387
+ ];
26388
+ return nodes;
25942
26389
  }
25943
26390
  /**
25944
26391
  * Build a commit URL for the repo when GitHub provider info is available.
@@ -26512,7 +26959,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
26512
26959
  const git = options.git || getRepo();
26513
26960
  const rows = options.rows || (await getLogRows(git, logArgv));
26514
26961
  await startInkInteractiveLog(git, rows, {}, {
26515
- appLabel: 'coco ui',
26962
+ appLabel: 'coco',
26516
26963
  idleTips: config.logTui?.idleTips,
26517
26964
  initialView: 'history',
26518
26965
  logArgv,
@@ -26525,7 +26972,7 @@ async function startCocoUi(argv) {
26525
26972
  const logArgv = createLogArgvFromUiArgv(argv);
26526
26973
  const rows = await getLogRows(git, logArgv);
26527
26974
  await startInkInteractiveLog(git, rows, {}, {
26528
- appLabel: 'coco ui',
26975
+ appLabel: 'coco',
26529
26976
  idleTips: config.logTui?.idleTips,
26530
26977
  initialView: argv.view || 'history',
26531
26978
  logArgv,