git-coco 0.35.0 → 0.36.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 +1244 -66
  2. package/dist/index.js +1245 -66
  3. package/package.json +1 -1
@@ -37,9 +37,11 @@ import '@langchain/core/utils/env';
37
37
  import '@langchain/core/utils/async_caller';
38
38
  import { encoding_for_model } from 'tiktoken';
39
39
  import { spawn, exec, execFile } from 'child_process';
40
+ import { spawnSync } from 'node:child_process';
40
41
  import * as fs$1 from 'node:fs';
41
42
  import * as os$1 from 'node:os';
42
43
  import * as path$1 from 'node:path';
44
+ import * as crypto from 'node:crypto';
43
45
  import * as readline from 'readline';
44
46
  import readline__default from 'readline';
45
47
  import { promisify } from 'util';
@@ -50,7 +52,7 @@ import { pathToFileURL } from 'url';
50
52
  /**
51
53
  * Current build version from package.json
52
54
  */
53
- const BUILD_VERSION = "0.35.0";
55
+ const BUILD_VERSION = "0.36.0";
54
56
 
55
57
  const isInteractive = (config) => {
56
58
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -13890,13 +13892,18 @@ function applyCommitComposeAction(state, action) {
13890
13892
  loading: action.value,
13891
13893
  };
13892
13894
  case 'setDraft':
13895
+ // No `message` here — the loader → filled fields are the confirmation
13896
+ // that the AI generated something. A lingering "AI draft ready for
13897
+ // editing" line in the panel reads as stale state. The runtime still
13898
+ // posts the same string to the footer status line for transient
13899
+ // feedback.
13893
13900
  return {
13894
13901
  ...state,
13895
13902
  ...splitCommitDraft(action.value),
13896
13903
  field: 'summary',
13897
13904
  editing: true,
13898
13905
  loading: false,
13899
- message: 'AI draft ready for editing',
13906
+ message: undefined,
13900
13907
  details: undefined,
13901
13908
  };
13902
13909
  case 'setResult':
@@ -14528,6 +14535,85 @@ function getLogInkWorkflowActions() {
14528
14535
  kind: 'normal',
14529
14536
  requiresConfirmation: false,
14530
14537
  },
14538
+ {
14539
+ // Per-view-only: scoped to the history view in inkInput so `c`
14540
+ // doesn't fire elsewhere. Empty key keeps it palette-discoverable
14541
+ // without registering a global hotkey.
14542
+ id: 'cherry-pick-commit',
14543
+ key: '',
14544
+ label: 'Cherry-pick commit',
14545
+ description: 'Apply the selected commit on top of the current branch (after confirmation).',
14546
+ kind: 'destructive',
14547
+ requiresConfirmation: true,
14548
+ },
14549
+ {
14550
+ // Per-view-only: scoped to the commit-diff explore in inkInput.
14551
+ // Routed through the y-confirm path because `git checkout <sha> --
14552
+ // <path>` overwrites the worktree file unconditionally and we
14553
+ // want the user to acknowledge that before discarding any local
14554
+ // edits to the path.
14555
+ id: 'checkout-file-from-commit',
14556
+ key: '',
14557
+ label: 'Cherry-pick file from commit',
14558
+ description: 'Materialize the selected file from this commit into the working tree (after confirmation).',
14559
+ kind: 'destructive',
14560
+ requiresConfirmation: true,
14561
+ },
14562
+ {
14563
+ // Per-view-only: scoped to the stash-diff explorer in inkInput.
14564
+ // Same overwrite rationale as `checkout-file-from-commit` — the
14565
+ // y-confirm path is the dirty-tree warning.
14566
+ id: 'checkout-file-from-stash',
14567
+ key: '',
14568
+ label: 'Cherry-pick file from stash',
14569
+ description: 'Materialize the selected file from this stash into the working tree (after confirmation).',
14570
+ kind: 'destructive',
14571
+ requiresConfirmation: true,
14572
+ },
14573
+ {
14574
+ id: 'open-pr',
14575
+ key: 'O',
14576
+ label: 'Open PR / repo',
14577
+ description: 'Open the current branch\'s pull request in the browser, or the repo page if there\'s no PR.',
14578
+ kind: 'normal',
14579
+ requiresConfirmation: false,
14580
+ },
14581
+ {
14582
+ id: 'fetch-remotes',
14583
+ key: 'F',
14584
+ label: 'Fetch all remotes',
14585
+ description: 'Run `git fetch --all --prune` and silently refresh context.',
14586
+ kind: 'normal',
14587
+ requiresConfirmation: false,
14588
+ },
14589
+ {
14590
+ id: 'pull-current-branch',
14591
+ key: 'U',
14592
+ label: 'Pull current branch',
14593
+ description: 'Run `git pull --ff-only` against the current branch.',
14594
+ kind: 'normal',
14595
+ requiresConfirmation: false,
14596
+ },
14597
+ {
14598
+ id: 'push-current-branch',
14599
+ key: 'P',
14600
+ label: 'Push current branch',
14601
+ description: 'Run `git push` for the current branch.',
14602
+ kind: 'normal',
14603
+ requiresConfirmation: false,
14604
+ },
14605
+ {
14606
+ // Per-view-only — the inkInput handler scopes this to the tags
14607
+ // surface so we don't expose `R` as a remote-delete from elsewhere.
14608
+ // The empty `key` keeps the workflow palette-discoverable but does
14609
+ // not register a global hotkey.
14610
+ id: 'delete-remote-tag',
14611
+ key: '',
14612
+ label: 'Delete remote tag',
14613
+ description: 'Push :tag to origin to delete the selected tag remotely after confirmation.',
14614
+ kind: 'destructive',
14615
+ requiresConfirmation: true,
14616
+ },
14531
14617
  {
14532
14618
  id: 'stage-file',
14533
14619
  key: 'space',
@@ -14775,6 +14861,13 @@ const LOG_INK_KEY_BINDINGS = [
14775
14861
  description: 'Push the stash view (gz; gs is reserved for status).',
14776
14862
  contexts: ['normal'],
14777
14863
  },
14864
+ {
14865
+ id: 'navigateWorktrees',
14866
+ keys: ['gw'],
14867
+ label: 'worktrees',
14868
+ description: 'Push the linked worktrees view.',
14869
+ contexts: ['normal'],
14870
+ },
14778
14871
  {
14779
14872
  id: 'navigateBack',
14780
14873
  keys: ['<', 'esc'],
@@ -14895,6 +14988,7 @@ const GLOBAL_BINDING_IDS = [
14895
14988
  'navigateBranches',
14896
14989
  'navigateTags',
14897
14990
  'navigateStash',
14991
+ 'navigateWorktrees',
14898
14992
  'navigateBack',
14899
14993
  ];
14900
14994
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -14973,37 +15067,57 @@ function getLogInkFooterHints(options) {
14973
15067
  };
14974
15068
  }
14975
15069
  if (options.activeView === 'diff') {
15070
+ if (options.diffSource === 'stash') {
15071
+ return {
15072
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'esc back'],
15073
+ global: NORMAL_GLOBAL_HINTS,
15074
+ };
15075
+ }
15076
+ if (options.diffSource === 'commit') {
15077
+ // Commit-diff explore: read-only diff, but `c` cherry-picks the
15078
+ // cursored file from the commit into the worktree.
15079
+ return {
15080
+ contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'esc back'],
15081
+ global: NORMAL_GLOBAL_HINTS,
15082
+ };
15083
+ }
14976
15084
  return {
14977
- contextual: ['j/k hunks', 'space stage', 'z revert', 'e/c compose', 'esc files'],
15085
+ contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'esc files'],
14978
15086
  global: NORMAL_GLOBAL_HINTS,
14979
15087
  };
14980
15088
  }
14981
15089
  if (options.activeView === 'compose') {
14982
15090
  return {
14983
- contextual: ['e edit', 'tab field', 'c commit', 'I AI draft', 'esc back'],
15091
+ contextual: ['e edit', 'c commit', 'I AI draft', 'gs hunks', 'esc back'],
14984
15092
  global: NORMAL_GLOBAL_HINTS,
14985
15093
  };
14986
15094
  }
14987
15095
  if (options.activeView === 'branches') {
14988
15096
  return {
14989
- contextual: ['↑/↓ branches', 's sort', 'D delete', 'X checkout', 'enter diff'],
15097
+ contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort'],
14990
15098
  global: NORMAL_GLOBAL_HINTS,
14991
15099
  };
14992
15100
  }
14993
15101
  if (options.activeView === 'tags') {
14994
15102
  return {
14995
- contextual: ['↑/↓ tags', 's sort', 'T create', 'X push', 'esc back'],
15103
+ contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort'],
14996
15104
  global: NORMAL_GLOBAL_HINTS,
14997
15105
  };
14998
15106
  }
14999
15107
  if (options.activeView === 'stash') {
15000
15108
  return {
15001
- contextual: ['↑/↓ stashes', 'A apply', 'D drop', 'esc back'],
15109
+ contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', 'X drop'],
15110
+ global: NORMAL_GLOBAL_HINTS,
15111
+ };
15112
+ }
15113
+ if (options.activeView === 'worktrees') {
15114
+ return {
15115
+ contextual: ['↑/↓ worktrees', 'W remove', 'esc back'],
15002
15116
  global: NORMAL_GLOBAL_HINTS,
15003
15117
  };
15004
15118
  }
15005
15119
  return {
15006
- contextual: ['↑/↓ move', '/ search', 'gg/G top/bottom', 'n/N next'],
15120
+ contextual: ['↑/↓ move', 'enter diff', 'c cherry-pick', '/ search', 'gg/G top/bottom'],
15007
15121
  global: NORMAL_GLOBAL_HINTS,
15008
15122
  };
15009
15123
  }
@@ -15266,10 +15380,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
15266
15380
  if (command.requiresConfirmation) {
15267
15381
  return [action({ type: 'setPendingConfirmation', value: command.id })];
15268
15382
  }
15269
- return [
15270
- action({ type: 'setWorkflowAction', value: command.id }),
15271
- action({ type: 'setStatus', value: `${command.label} selected` }),
15272
- ];
15383
+ // Non-confirm workflows are dispatched directly through the runtime
15384
+ // workflow runner same path the keyboard takes. Previously this
15385
+ // emitted `setWorkflowAction` only, which set state but never fired
15386
+ // the action because nothing in the runtime consumes
15387
+ // `workflowActionId`.
15388
+ return [{ type: 'runWorkflowAction', id: command.id }];
15273
15389
  }
15274
15390
  // Binding-derived commands. Map each LogInkCommandId to the same events
15275
15391
  // the keystroke would emit. Order matches the keymap registry.
@@ -15331,6 +15447,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
15331
15447
  return [action({ type: 'pushView', value: 'tags' })];
15332
15448
  case 'navigateStash':
15333
15449
  return [action({ type: 'pushView', value: 'stash' })];
15450
+ case 'navigateWorktrees':
15451
+ return [action({ type: 'pushView', value: 'worktrees' })];
15334
15452
  case 'navigateBack':
15335
15453
  return [action({ type: 'popView' })];
15336
15454
  case 'openSelected': {
@@ -15429,6 +15547,39 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15429
15547
  }
15430
15548
  return [{ type: 'exit' }];
15431
15549
  }
15550
+ // Input prompt is the most modal — when active, every keystroke routes
15551
+ // into the prompt until Enter (submit) or Esc (cancel). Sits above the
15552
+ // filter/confirmation/compose handlers so a prompt opened from inside
15553
+ // any of those still captures focus cleanly.
15554
+ if (state.inputPrompt) {
15555
+ if (key.escape) {
15556
+ return [
15557
+ action({ type: 'closeInputPrompt' }),
15558
+ action({ type: 'setStatus', value: 'cancelled' }),
15559
+ ];
15560
+ }
15561
+ if (key.return) {
15562
+ const value = state.inputPrompt.value.trim();
15563
+ if (!value) {
15564
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
15565
+ }
15566
+ const id = state.inputPrompt.kind;
15567
+ return [
15568
+ { type: 'runWorkflowAction', id, payload: value },
15569
+ action({ type: 'closeInputPrompt' }),
15570
+ ];
15571
+ }
15572
+ if (key.backspace || key.delete) {
15573
+ return [action({ type: 'backspaceInputPrompt' })];
15574
+ }
15575
+ if (key.ctrl && inputValue === 'u') {
15576
+ return [action({ type: 'clearInputPromptText' })];
15577
+ }
15578
+ if (inputValue && !key.ctrl && !key.meta) {
15579
+ return [action({ type: 'appendInputPrompt', value: inputValue })];
15580
+ }
15581
+ return [];
15582
+ }
15432
15583
  if (state.commitCompose.editing) {
15433
15584
  if (key.escape) {
15434
15585
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -15497,7 +15648,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15497
15648
  // selected item and run the right action function.
15498
15649
  if (workflowAction) {
15499
15650
  return [
15500
- { type: 'runWorkflowAction', id: workflowAction.id },
15651
+ { type: 'runWorkflowAction', id: workflowAction.id, payload: state.pendingConfirmationPayload },
15501
15652
  action({ type: 'setPendingConfirmation', value: undefined }),
15502
15653
  ];
15503
15654
  }
@@ -15647,6 +15798,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15647
15798
  action({ type: 'setStatus', value: 'jumped to stash' }),
15648
15799
  ];
15649
15800
  }
15801
+ if (state.pendingKey === 'g' && inputValue === 'w') {
15802
+ return [
15803
+ action({ type: 'pushView', value: 'worktrees' }),
15804
+ action({ type: 'setStatus', value: 'jumped to worktrees' }),
15805
+ ];
15806
+ }
15650
15807
  if (inputValue === 'g') {
15651
15808
  if (state.pendingKey === 'g') {
15652
15809
  return [
@@ -15698,6 +15855,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15698
15855
  hunkOffsets: context.worktreeHunkOffsets,
15699
15856
  })];
15700
15857
  }
15858
+ if (state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffFileOffsets?.length) {
15859
+ return [action({
15860
+ type: 'jumpCommitDiffHunk',
15861
+ delta: -1,
15862
+ hunkOffsets: context.stashDiffFileOffsets,
15863
+ })];
15864
+ }
15701
15865
  if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15702
15866
  return [action({
15703
15867
  type: 'jumpCommitDiffHunk',
@@ -15715,6 +15879,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15715
15879
  hunkOffsets: context.worktreeHunkOffsets,
15716
15880
  })];
15717
15881
  }
15882
+ if (state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffFileOffsets?.length) {
15883
+ return [action({
15884
+ type: 'jumpCommitDiffHunk',
15885
+ delta: 1,
15886
+ hunkOffsets: context.stashDiffFileOffsets,
15887
+ })];
15888
+ }
15718
15889
  if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15719
15890
  return [action({
15720
15891
  type: 'jumpCommitDiffHunk',
@@ -15768,6 +15939,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15768
15939
  if (state.activeView === 'stash' && context.stashCount) {
15769
15940
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
15770
15941
  }
15942
+ if (state.activeView === 'worktrees' && context.worktreeListCount) {
15943
+ return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
15944
+ }
15771
15945
  if (state.activeView === 'history' &&
15772
15946
  state.focus === 'commits' &&
15773
15947
  state.selectedIndex === 0 &&
@@ -15818,6 +15992,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15818
15992
  if (state.activeView === 'stash' && context.stashCount) {
15819
15993
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
15820
15994
  }
15995
+ if (state.activeView === 'worktrees' && context.worktreeListCount) {
15996
+ return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
15997
+ }
15821
15998
  return [
15822
15999
  action(state.focus === 'sidebar'
15823
16000
  ? { type: 'nextSidebarTab' }
@@ -15918,12 +16095,183 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15918
16095
  })];
15919
16096
  }
15920
16097
  }
15921
- if (key.return && state.activeView === 'status' && context.worktreeFileCount) {
16098
+ // Enter on a sidebar tab drills into the corresponding promoted view
16099
+ // (status / branches / tags / stash). Sits above the per-view Enter
16100
+ // handlers so a sidebar-focused Enter never fires checkout-branch /
16101
+ // navigateOpenDiffForCommit / etc. against the (hidden) selection in
16102
+ // the active tab.
16103
+ //
16104
+ // The Enter also moves focus out of the sidebar into the newly opened
16105
+ // list — otherwise ↑/↓ keep cycling sidebar tabs instead of navigating
16106
+ // inside the just-opened view, which made the drill-in feel half-done.
16107
+ if (key.return && state.focus === 'sidebar') {
16108
+ const tabToView = {
16109
+ status: 'status',
16110
+ branches: 'branches',
16111
+ tags: 'tags',
16112
+ stashes: 'stash',
16113
+ worktrees: 'worktrees',
16114
+ };
16115
+ const target = tabToView[state.sidebarTab];
16116
+ if (target) {
16117
+ return [
16118
+ action({ type: 'pushView', value: target }),
16119
+ action({ type: 'setFocus', value: 'commits' }),
16120
+ ];
16121
+ }
16122
+ return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
16123
+ }
16124
+ if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
15922
16125
  return [action({
15923
16126
  type: 'navigateOpenDiffForWorktreeFile',
15924
16127
  fileIndex: state.selectedWorktreeFileIndex,
15925
16128
  })];
15926
16129
  }
16130
+ // Enter on a branch row checks the branch out. Non-destructive workflow
16131
+ // action — no confirmation prompt.
16132
+ if (key.return && state.activeView === 'branches' && state.focus === 'commits' && context.branchCount) {
16133
+ return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
16134
+ }
16135
+ // `+` opens a create-branch / create-tag prompt depending on context.
16136
+ // Works from either the matching promoted view (active branches /
16137
+ // tags surface) or from the sidebar when the corresponding tab is
16138
+ // active — saves a drill-in for "I just want to make a new branch".
16139
+ const wantsCreateBranch = inputValue === '+' && (state.activeView === 'branches' ||
16140
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches'));
16141
+ const wantsCreateTag = inputValue === '+' && (state.activeView === 'tags' ||
16142
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags'));
16143
+ if (wantsCreateBranch) {
16144
+ return [action({
16145
+ type: 'openInputPrompt',
16146
+ kind: 'create-branch',
16147
+ label: 'New branch name',
16148
+ })];
16149
+ }
16150
+ if (wantsCreateTag) {
16151
+ return [action({
16152
+ type: 'openInputPrompt',
16153
+ kind: 'create-tag',
16154
+ label: 'New tag name',
16155
+ })];
16156
+ }
16157
+ // Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
16158
+ // then drop). Drop is the existing destructive `X` workflow which
16159
+ // routes through the y-confirm path. Scoped to the stash view so the
16160
+ // letters stay free elsewhere.
16161
+ if (inputValue === 'a' && state.activeView === 'stash' && context.stashCount) {
16162
+ return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
16163
+ }
16164
+ if (inputValue === 'p' && state.activeView === 'stash' && context.stashCount) {
16165
+ return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
16166
+ }
16167
+ // Per-view tag action: `P` pushes the selected tag to origin. Letter
16168
+ // is scoped to the tags surface so it doesn't collide with `p` for
16169
+ // pop-stash. Note: this also takes precedence over the global
16170
+ // push-current-branch workflow's `P` key.
16171
+ if (inputValue === 'P' && state.activeView === 'tags' && context.tagCount) {
16172
+ return [{ type: 'runWorkflowAction', id: 'push-tag' }];
16173
+ }
16174
+ // Per-view branches actions: `R` renames the selected branch, `u`
16175
+ // sets its upstream. Both open the input prompt so the user can type
16176
+ // the new value. Pre-fills are handled by the prompt's `initial`.
16177
+ if (inputValue === 'R' && state.activeView === 'branches' && context.branchCount) {
16178
+ return [action({
16179
+ type: 'openInputPrompt',
16180
+ kind: 'rename-branch',
16181
+ label: 'Rename branch to',
16182
+ })];
16183
+ }
16184
+ if (inputValue === 'u' && state.activeView === 'branches' && context.branchCount) {
16185
+ return [action({
16186
+ type: 'openInputPrompt',
16187
+ kind: 'set-upstream',
16188
+ label: 'Upstream ref (e.g. origin/main)',
16189
+ })];
16190
+ }
16191
+ // Per-view tag action: `R` deletes the tag from the remote (after
16192
+ // confirmation). Scoped per-view so this letter is free elsewhere
16193
+ // (especially the `R` rename binding on the branches view).
16194
+ if (inputValue === 'R' && state.activeView === 'tags' && context.tagCount) {
16195
+ return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
16196
+ }
16197
+ // Global stash hotkey: `S` opens a stash-message prompt and
16198
+ // `createStash` runs once submitted. Available everywhere there's
16199
+ // not a more modal handler in front of it.
16200
+ if (inputValue === 'S') {
16201
+ return [action({
16202
+ type: 'openInputPrompt',
16203
+ kind: 'create-stash',
16204
+ label: 'Stash message',
16205
+ })];
16206
+ }
16207
+ // `o` opens the file under the cursor in $EDITOR. Available on the
16208
+ // status surface (worktree files), the worktree diff (the file being
16209
+ // diffed), and the stash diff (the file the cursor sits in inside
16210
+ // the patch). The runtime suspends Ink, spawns the editor sync, then
16211
+ // re-renders.
16212
+ if (inputValue === 'o' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
16213
+ return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
16214
+ }
16215
+ if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'worktree' && context.worktreeSelectedPath) {
16216
+ return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
16217
+ }
16218
+ if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
16219
+ return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
16220
+ }
16221
+ // `c` on a stash diff cherry-picks the file under the cursor —
16222
+ // materializes that single path from the stash into the working tree
16223
+ // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
16224
+ // path because the checkout overwrites the worktree file
16225
+ // unconditionally; the prompt is the user's chance to abort if they
16226
+ // have unsaved edits at that path.
16227
+ if (inputValue === 'c' &&
16228
+ state.activeView === 'diff' &&
16229
+ state.diffSource === 'stash' &&
16230
+ context.stashDiffSelectedPath &&
16231
+ state.stashDiffRef) {
16232
+ return [action({
16233
+ type: 'setPendingConfirmation',
16234
+ value: 'checkout-file-from-stash',
16235
+ payload: context.stashDiffSelectedPath,
16236
+ })];
16237
+ }
16238
+ // `c` on a commit-diff explore cherry-picks the cursored file from
16239
+ // that historical commit — `git checkout <sha> -- <path>`. Same
16240
+ // confirmation rationale as the stash variant. The payload encodes
16241
+ // both the sha and the path so the runtime handler doesn't have to
16242
+ // re-resolve either.
16243
+ if (inputValue === 'c' &&
16244
+ state.activeView === 'diff' &&
16245
+ state.diffSource === 'commit' &&
16246
+ context.commitDiffSelectedPath &&
16247
+ context.commitDiffSelectedSha) {
16248
+ return [action({
16249
+ type: 'setPendingConfirmation',
16250
+ value: 'checkout-file-from-commit',
16251
+ payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
16252
+ })];
16253
+ }
16254
+ // `c` on the history view cherry-picks the full selected commit on
16255
+ // top of the current branch. Routed through the y-confirm flow since
16256
+ // it can produce conflicts and is a real working-tree mutation.
16257
+ if (inputValue === 'c' &&
16258
+ state.activeView === 'history' &&
16259
+ state.focus === 'commits' &&
16260
+ state.filteredCommits.length > 0 &&
16261
+ !state.pendingCommitFocused) {
16262
+ return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
16263
+ }
16264
+ // Enter on a stash row pushes the diff view scoped to that stash.
16265
+ // The runtime loads `git stash show -p <ref>` once the view is
16266
+ // active. The stash ref is passed via the action so we don't need a
16267
+ // context lookup here.
16268
+ if (key.return && state.activeView === 'stash' && state.focus === 'commits' && context.stashCount && context.stashSelectedRef) {
16269
+ return [action({
16270
+ type: 'navigateOpenDiffForStash',
16271
+ ref: context.stashSelectedRef,
16272
+ stashIndex: state.selectedStashIndex,
16273
+ })];
16274
+ }
15927
16275
  if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
15928
16276
  return [{ type: 'toggleSelectedFileStage' }];
15929
16277
  }
@@ -15959,10 +16307,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15959
16307
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
15960
16308
  }
15961
16309
  if (workflowAction) {
15962
- return [
15963
- action({ type: 'setWorkflowAction', value: workflowAction.id }),
15964
- action({ type: 'setStatus', value: `${workflowAction.label} selected` }),
15965
- ];
16310
+ // Non-destructive workflow — fire it directly via the runtime
16311
+ // handler. The handler surfaces success/failure on the status line
16312
+ // and silently refreshes context so the list updates.
16313
+ return [{ type: 'runWorkflowAction', id: workflowAction.id }];
15966
16314
  }
15967
16315
  return [];
15968
16316
  }
@@ -15978,7 +16326,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15978
16326
  * fall back to "already seen" so we never block startup.
15979
16327
  */
15980
16328
  const MARKER_BASENAME = 'onboarding.seen';
15981
- function resolveCacheDir() {
16329
+ function resolveCacheDir$1() {
15982
16330
  const xdg = process.env.XDG_CACHE_HOME;
15983
16331
  if (xdg && xdg.trim().length > 0) {
15984
16332
  return path$1.join(xdg, 'coco');
@@ -15986,7 +16334,7 @@ function resolveCacheDir() {
15986
16334
  return path$1.join(os$1.homedir(), '.cache', 'coco');
15987
16335
  }
15988
16336
  function getOnboardingMarkerPath() {
15989
- return path$1.join(resolveCacheDir(), MARKER_BASENAME);
16337
+ return path$1.join(resolveCacheDir$1(), MARKER_BASENAME);
15990
16338
  }
15991
16339
  function hasSeenOnboarding() {
15992
16340
  try {
@@ -16010,6 +16358,65 @@ function markOnboardingSeen() {
16010
16358
  }
16011
16359
  }
16012
16360
 
16361
+ /**
16362
+ * Persist which sidebar tab the user last had active, keyed per repo so
16363
+ * switching projects doesn't reset every other repo's preference. The
16364
+ * cache lives next to the onboarding marker (XDG-friendly) and is
16365
+ * best-effort: read/write failures fall back to the default sidebar
16366
+ * tab on next start.
16367
+ *
16368
+ * Repos are keyed by a short hash of their absolute path — no PII in
16369
+ * the cache filename, and re-creating a repo at the same path keeps
16370
+ * the same preference.
16371
+ */
16372
+ const VALID_TABS = [
16373
+ 'status',
16374
+ 'branches',
16375
+ 'tags',
16376
+ 'stashes',
16377
+ 'worktrees',
16378
+ ];
16379
+ function resolveCacheDir() {
16380
+ const xdg = process.env.XDG_CACHE_HOME;
16381
+ if (xdg && xdg.trim().length > 0) {
16382
+ return path$1.join(xdg, 'coco');
16383
+ }
16384
+ return path$1.join(os$1.homedir(), '.cache', 'coco');
16385
+ }
16386
+ function repoKey(repoPath) {
16387
+ // sha1 is used here as a non-security cache-key derivation — we just
16388
+ // need a deterministic short identifier for the marker filename so
16389
+ // re-creating a repo at the same path keeps the same preference.
16390
+ // No PII or auth context is hashed; no collision-resistance against
16391
+ // an adversary is required. DevSkim DS126858 doesn't apply.
16392
+ // DevSkim: ignore DS126858
16393
+ return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
16394
+ }
16395
+ function getSidebarTabMarkerPath(repoPath) {
16396
+ return path$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
16397
+ }
16398
+ function getSavedSidebarTab(repoPath) {
16399
+ try {
16400
+ const raw = fs$1.readFileSync(getSidebarTabMarkerPath(repoPath), 'utf8').trim();
16401
+ return VALID_TABS.includes(raw)
16402
+ ? raw
16403
+ : undefined;
16404
+ }
16405
+ catch {
16406
+ return undefined;
16407
+ }
16408
+ }
16409
+ function saveSidebarTab(repoPath, tab) {
16410
+ const marker = getSidebarTabMarkerPath(repoPath);
16411
+ try {
16412
+ fs$1.mkdirSync(path$1.dirname(marker), { recursive: true });
16413
+ fs$1.writeFileSync(marker, tab);
16414
+ }
16415
+ catch {
16416
+ // Best-effort persistence; swallow.
16417
+ }
16418
+ }
16419
+
16013
16420
  /**
16014
16421
  * Promoted-view selection rectification on filter changes (P4.5).
16015
16422
  *
@@ -16263,7 +16670,12 @@ function getLogInkLayout(input) {
16263
16670
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
16264
16671
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
16265
16672
  const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
16266
- const sidebarWidth = Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
16673
+ // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
16674
+ // (~36% of width). The transition is instant per render — focus tab to
16675
+ // expand, focus away to collapse.
16676
+ const sidebarWidth = input.sidebarFocused
16677
+ ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
16678
+ : Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
16267
16679
  return {
16268
16680
  bodyRows: Math.max(8, rows - 5),
16269
16681
  columns,
@@ -17003,9 +17415,19 @@ function withPushedView(state, value) {
17003
17415
  ...state,
17004
17416
  activeView: value,
17005
17417
  viewStack,
17418
+ // The compose + status views' right detail panels already show
17419
+ // worktree info, so keeping the left sidebar on the Status tab
17420
+ // duplicates that information. Auto-switch to Branches when entering
17421
+ // either view; the user can swap back with [/] if they want.
17422
+ //
17423
+ // We update only the rendered `sidebarTab` here, never
17424
+ // `userSidebarTab`, so this auto-switch is invisible to per-repo
17425
+ // persistence and pop-view restores the previous tab.
17426
+ sidebarTab: value === 'compose' || value === 'status' ? 'branches' : state.sidebarTab,
17006
17427
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
17007
17428
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17008
17429
  diffSource: value === 'diff' ? state.diffSource : undefined,
17430
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17009
17431
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17010
17432
  pendingKey: undefined,
17011
17433
  };
@@ -17020,9 +17442,14 @@ function withPoppedView(state) {
17020
17442
  ...state,
17021
17443
  activeView: next,
17022
17444
  viewStack,
17445
+ // Restore the user's last explicit tab choice so popping out of
17446
+ // compose / status (which auto-switch the sidebar to Branches)
17447
+ // returns the user to whatever they actually had open before.
17448
+ sidebarTab: state.userSidebarTab,
17023
17449
  worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
17024
17450
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17025
17451
  diffSource: next === 'diff' ? state.diffSource : undefined,
17452
+ stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
17026
17453
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
17027
17454
  pendingKey: undefined,
17028
17455
  };
@@ -17039,6 +17466,7 @@ function withReplacedView(state, value) {
17039
17466
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
17040
17467
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17041
17468
  diffSource: value === 'diff' ? state.diffSource : undefined,
17469
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17042
17470
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17043
17471
  pendingKey: undefined,
17044
17472
  };
@@ -17131,6 +17559,7 @@ function createLogInkState(rows, options = {}) {
17131
17559
  selectedBranchIndex: 0,
17132
17560
  selectedTagIndex: 0,
17133
17561
  selectedStashIndex: 0,
17562
+ selectedWorktreeListIndex: 0,
17134
17563
  branchSort: DEFAULT_BRANCH_SORT_MODE,
17135
17564
  tagSort: DEFAULT_TAG_SORT_MODE,
17136
17565
  paletteFilter: '',
@@ -17146,10 +17575,12 @@ function createLogInkState(rows, options = {}) {
17146
17575
  showCommandPalette: false,
17147
17576
  workflowActionId: undefined,
17148
17577
  pendingConfirmationId: undefined,
17578
+ pendingConfirmationPayload: undefined,
17149
17579
  pendingMutationConfirmation: undefined,
17150
17580
  pendingKey: undefined,
17151
17581
  focus: 'commits',
17152
17582
  sidebarTab: 'status',
17583
+ userSidebarTab: 'status',
17153
17584
  };
17154
17585
  }
17155
17586
  function getSelectedInkCommit(state) {
@@ -17256,6 +17687,12 @@ function applyLogInkAction(state, action) {
17256
17687
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
17257
17688
  pendingKey: undefined,
17258
17689
  };
17690
+ case 'moveWorktreeListEntry':
17691
+ return {
17692
+ ...state,
17693
+ selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
17694
+ pendingKey: undefined,
17695
+ };
17259
17696
  case 'cycleBranchSort':
17260
17697
  return {
17261
17698
  ...state,
@@ -17272,6 +17709,30 @@ function applyLogInkAction(state, action) {
17272
17709
  selectedTagIndex: 0,
17273
17710
  pendingKey: undefined,
17274
17711
  };
17712
+ case 'openInputPrompt':
17713
+ return {
17714
+ ...state,
17715
+ inputPrompt: {
17716
+ kind: action.kind,
17717
+ label: action.label,
17718
+ value: action.initial || '',
17719
+ },
17720
+ pendingKey: undefined,
17721
+ };
17722
+ case 'appendInputPrompt':
17723
+ return state.inputPrompt
17724
+ ? { ...state, inputPrompt: { ...state.inputPrompt, value: `${state.inputPrompt.value}${action.value}` } }
17725
+ : state;
17726
+ case 'backspaceInputPrompt':
17727
+ return state.inputPrompt
17728
+ ? { ...state, inputPrompt: { ...state.inputPrompt, value: state.inputPrompt.value.slice(0, -1) } }
17729
+ : state;
17730
+ case 'clearInputPromptText':
17731
+ return state.inputPrompt
17732
+ ? { ...state, inputPrompt: { ...state.inputPrompt, value: '' } }
17733
+ : state;
17734
+ case 'closeInputPrompt':
17735
+ return { ...state, inputPrompt: undefined, pendingKey: undefined };
17275
17736
  case 'moveToBottom':
17276
17737
  return {
17277
17738
  ...state,
@@ -17290,12 +17751,15 @@ function applyLogInkAction(state, action) {
17290
17751
  pendingCommitFocused: false,
17291
17752
  pendingKey: undefined,
17292
17753
  };
17293
- case 'nextSidebarTab':
17754
+ case 'nextSidebarTab': {
17755
+ const next = cycleValue(SIDEBAR_TABS, state.sidebarTab, 1);
17294
17756
  return {
17295
17757
  ...state,
17296
- sidebarTab: cycleValue(SIDEBAR_TABS, state.sidebarTab, 1),
17758
+ sidebarTab: next,
17759
+ userSidebarTab: next,
17297
17760
  pendingKey: undefined,
17298
17761
  };
17762
+ }
17299
17763
  case 'page':
17300
17764
  return {
17301
17765
  ...state,
@@ -17329,12 +17793,15 @@ function applyLogInkAction(state, action) {
17329
17793
  diffPreviewOffset: nextHunkOffset(state.diffPreviewOffset, action.hunkOffsets, action.delta),
17330
17794
  pendingKey: undefined,
17331
17795
  };
17332
- case 'previousSidebarTab':
17796
+ case 'previousSidebarTab': {
17797
+ const previous = cycleValue(SIDEBAR_TABS, state.sidebarTab, -1);
17333
17798
  return {
17334
17799
  ...state,
17335
- sidebarTab: cycleValue(SIDEBAR_TABS, state.sidebarTab, -1),
17800
+ sidebarTab: previous,
17801
+ userSidebarTab: previous,
17336
17802
  pendingKey: undefined,
17337
17803
  };
17804
+ }
17338
17805
  case 'setFilter':
17339
17806
  return withFilter$1(state, action.value, action.promotedSelections);
17340
17807
  case 'setActiveView':
@@ -17382,6 +17849,21 @@ function applyLogInkAction(state, action) {
17382
17849
  diffSource: 'worktree',
17383
17850
  };
17384
17851
  }
17852
+ case 'navigateOpenDiffForStash': {
17853
+ const next = withPushedView(state, 'diff');
17854
+ return {
17855
+ ...next,
17856
+ diffSource: 'stash',
17857
+ stashDiffRef: action.ref,
17858
+ selectedStashIndex: Math.max(0, action.stashIndex ?? state.selectedStashIndex),
17859
+ // Reset the diff scroll offset so the stash patch always opens
17860
+ // at the top, mirroring `navigateOpenDiffForCommit`. Without
17861
+ // this, opening a stash inherits whatever offset the previous
17862
+ // diff had, landing the user mid-patch.
17863
+ diffPreviewOffset: 0,
17864
+ worktreeDiffOffset: 0,
17865
+ };
17866
+ }
17385
17867
  case 'navigateOpenComposeForFile': {
17386
17868
  const next = withPushedView(state, 'status');
17387
17869
  return {
@@ -17406,9 +17888,22 @@ function applyLogInkAction(state, action) {
17406
17888
  return {
17407
17889
  ...state,
17408
17890
  sidebarTab: action.value,
17891
+ userSidebarTab: action.value,
17409
17892
  focus: 'sidebar',
17410
17893
  pendingKey: undefined,
17411
17894
  };
17895
+ case 'restoreSidebarTab':
17896
+ // Mount-time restore from per-repo persistence (#21). Updates the
17897
+ // tab + the user-choice mirror without forcing focus into the
17898
+ // sidebar — that's the focus-steal regression flagged in the PR
17899
+ // review. Users land on commits as usual; their saved tab is
17900
+ // visible in the sidebar but doesn't grab the cursor.
17901
+ return {
17902
+ ...state,
17903
+ sidebarTab: action.value,
17904
+ userSidebarTab: action.value,
17905
+ pendingKey: undefined,
17906
+ };
17412
17907
  case 'setStatus':
17413
17908
  return {
17414
17909
  ...state,
@@ -17420,6 +17915,7 @@ function applyLogInkAction(state, action) {
17420
17915
  ...state,
17421
17916
  workflowActionId: action.value,
17422
17917
  pendingConfirmationId: undefined,
17918
+ pendingConfirmationPayload: undefined,
17423
17919
  pendingMutationConfirmation: undefined,
17424
17920
  pendingKey: undefined,
17425
17921
  };
@@ -17427,6 +17923,7 @@ function applyLogInkAction(state, action) {
17427
17923
  return {
17428
17924
  ...state,
17429
17925
  pendingConfirmationId: action.value,
17926
+ pendingConfirmationPayload: action.value ? action.payload : undefined,
17430
17927
  workflowActionId: action.value ? undefined : state.workflowActionId,
17431
17928
  pendingMutationConfirmation: action.value ? undefined : state.pendingMutationConfirmation,
17432
17929
  pendingKey: undefined,
@@ -17436,6 +17933,7 @@ function applyLogInkAction(state, action) {
17436
17933
  ...state,
17437
17934
  pendingMutationConfirmation: action.value,
17438
17935
  pendingConfirmationId: action.value ? undefined : state.pendingConfirmationId,
17936
+ pendingConfirmationPayload: action.value ? undefined : state.pendingConfirmationPayload,
17439
17937
  workflowActionId: action.value ? undefined : state.workflowActionId,
17440
17938
  pendingKey: undefined,
17441
17939
  };
@@ -18257,6 +18755,36 @@ function cherryPickCommit(git, commit) {
18257
18755
  }
18258
18756
  return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['cherry-pick', commit.hash]), `Cherry-picked ${commit.shortHash}`)));
18259
18757
  }
18758
+ /**
18759
+ * Materialize a single file's contents from a historical commit into the
18760
+ * working tree, leaving every other path untouched. Equivalent to
18761
+ * `git checkout <sha> -- <path>` for additions/modifications. When the
18762
+ * path no longer exists at <sha> (i.e. the commit deleted that file),
18763
+ * mirror the deletion in the worktree via `git rm --force`.
18764
+ *
18765
+ * Important: this overwrites the file in the working tree. The caller
18766
+ * is responsible for confirming with the user when the working tree
18767
+ * already has uncommitted changes to that path.
18768
+ */
18769
+ async function checkoutFileFromCommit(git, sha, path) {
18770
+ return checkoutOrDeleteFromRef(git, sha, path, sha.slice(0, 7));
18771
+ }
18772
+ async function checkoutOrDeleteFromRef(git, ref, path, label) {
18773
+ const exists = await pathExistsAtRef(git, ref, path);
18774
+ if (exists) {
18775
+ return runAction$5(() => git.raw(['checkout', ref, '--', path]), `Checked out ${path} from ${label}`);
18776
+ }
18777
+ return runAction$5(() => git.raw(['rm', '--force', '--quiet', '--', path]), `Removed ${path} (mirrors deletion from ${label})`);
18778
+ }
18779
+ async function pathExistsAtRef(git, ref, path) {
18780
+ try {
18781
+ await git.raw(['cat-file', '-e', `${ref}:${path}`]);
18782
+ return true;
18783
+ }
18784
+ catch {
18785
+ return false;
18786
+ }
18787
+ }
18260
18788
  function revertCommit(git, commit) {
18261
18789
  if (!commit) {
18262
18790
  return Promise.resolve({
@@ -18406,6 +18934,20 @@ function popStash(git, stash) {
18406
18934
  function dropStash(git, stash) {
18407
18935
  return runAction$4(() => git.raw(['stash', 'drop', stash.ref]), `Dropped ${stash.ref}`);
18408
18936
  }
18937
+ /**
18938
+ * Materialize a single file's contents from a stash into the working
18939
+ * tree, leaving the rest of the stash untouched. Equivalent to
18940
+ * `git checkout <stashRef> -- <path>` for additions/modifications. When
18941
+ * the path doesn't exist at <stashRef> — i.e. the stash recorded a
18942
+ * deletion — mirror that deletion in the worktree.
18943
+ *
18944
+ * Important: this overwrites the file in the working tree. The caller
18945
+ * is responsible for confirming with the user when the working tree
18946
+ * already has uncommitted changes to that path.
18947
+ */
18948
+ function checkoutFileFromStash(git, stashRef, path) {
18949
+ return checkoutOrDeleteFromRef(git, stashRef, path, stashRef);
18950
+ }
18409
18951
 
18410
18952
  function parseStashSubject(subject) {
18411
18953
  const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
@@ -18458,6 +19000,68 @@ async function getStashDiffSummary(git, stashRef) {
18458
19000
  .map((line) => line.trimEnd())
18459
19001
  .filter(Boolean);
18460
19002
  }
19003
+ /**
19004
+ * Full unified-patch diff for a stash. Used by the diff surface when
19005
+ * `state.diffSource === 'stash'` to render the stash's changes inline.
19006
+ *
19007
+ * Empty stashes (e.g. created by `git stash --keep-index` against an
19008
+ * already-clean tree) return [] rather than throwing — surfaces fall
19009
+ * back to a "no diff to display" message.
19010
+ */
19011
+ async function getStashDiff(git, stashRef) {
19012
+ return (await git.raw(['stash', 'show', '-p', stashRef]))
19013
+ .split('\n')
19014
+ .map((line) => line.replace(/\r$/, ''));
19015
+ }
19016
+ /**
19017
+ * Slice a unified-patch into per-file sections. Each entry records the
19018
+ * file path and the offset of its `diff --git` header within `lines`.
19019
+ * Used by the stash explorer to build a per-file cursor + cherry-pick
19020
+ * the file at the cursor.
19021
+ *
19022
+ * Renames / moves return the destination path (the `b/` side); the
19023
+ * action surface treats that as the path to materialize from the stash.
19024
+ *
19025
+ * Path quoting: git wraps paths containing spaces or special characters
19026
+ * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
19027
+ * The parser handles both the unquoted and quoted forms; without that,
19028
+ * stash-file navigation and cherry-pick silently broke for any file
19029
+ * whose path contained a space.
19030
+ */
19031
+ function parseStashDiffFiles(lines) {
19032
+ const files = [];
19033
+ for (let i = 0; i < lines.length; i += 1) {
19034
+ const line = lines[i];
19035
+ const parsed = parseDiffGitHeader(line);
19036
+ if (parsed) {
19037
+ files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
19038
+ }
19039
+ }
19040
+ return files;
19041
+ }
19042
+ const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
19043
+ function parseDiffGitHeader(line) {
19044
+ const match = line.match(DIFF_GIT_HEADER);
19045
+ if (!match)
19046
+ return undefined;
19047
+ const aPath = unescapeGitQuoted(match[1]) || match[2];
19048
+ const bPath = unescapeGitQuoted(match[3]) || match[4];
19049
+ if (!aPath || !bPath)
19050
+ return undefined;
19051
+ return { aPath, bPath };
19052
+ }
19053
+ function unescapeGitQuoted(value) {
19054
+ if (value === undefined)
19055
+ return undefined;
19056
+ // Git's diff header quoting escapes `"`, `\`, and the usual
19057
+ // C-style sequences. Reverse the most common ones so callers get the
19058
+ // raw on-disk path.
19059
+ return value
19060
+ .replace(/\\\\/g, '\\')
19061
+ .replace(/\\"/g, '"')
19062
+ .replace(/\\t/g, '\t')
19063
+ .replace(/\\n/g, '\n');
19064
+ }
18461
19065
 
18462
19066
  async function runAction$3(action, successMessage) {
18463
19067
  try {
@@ -21062,10 +21666,6 @@ function LogInkApp(deps) {
21062
21666
  const h = React.createElement;
21063
21667
  const { exit } = useApp();
21064
21668
  const windowSize = useWindowSize();
21065
- const layout = getLogInkLayout({
21066
- columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
21067
- rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
21068
- });
21069
21669
  // Bumping this on SIGCONT forces the existing tree to repaint so users
21070
21670
  // land on a drawn screen after `fg` instead of an empty alt buffer.
21071
21671
  const [, setResumeTick] = React.useState(0);
@@ -21093,6 +21693,13 @@ function LogInkApp(deps) {
21093
21693
  const [worktreeDiffLoading, setWorktreeDiffLoading] = React.useState(false);
21094
21694
  const [worktreeHunks, setWorktreeHunks] = React.useState(undefined);
21095
21695
  const [worktreeHunksLoading, setWorktreeHunksLoading] = React.useState(false);
21696
+ // Stash diff explorer (Enter on a stash row): the runtime fetches
21697
+ // `git stash show -p <ref>` lazily once the diff view becomes active
21698
+ // with diffSource='stash'. Lines are stored as a flat string[] —
21699
+ // renderDiffSurface paints each line through diffLineProps so +/-
21700
+ // colors match the commit-diff path.
21701
+ const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
21702
+ const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
21096
21703
  const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
21097
21704
  getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
21098
21705
  const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
@@ -21137,6 +21744,36 @@ function LogInkApp(deps) {
21137
21744
  const dispatch = React.useCallback((action) => {
21138
21745
  setState((current) => applyLogInkAction(current, action));
21139
21746
  }, []);
21747
+ // Auto-dismiss status messages after a short window so transient
21748
+ // confirmations ("Pulled current branch", "Edited foo.ts") don't
21749
+ // linger forever. Each new message resets the timer; clearing the
21750
+ // message via setStatus(undefined) cancels it. Doesn't fire while a
21751
+ // modal (input prompt, confirmation, palette) is open — those flows
21752
+ // use the status line as live feedback for the open task.
21753
+ React.useEffect(() => {
21754
+ if (!state.statusMessage)
21755
+ return;
21756
+ if (state.inputPrompt || state.pendingConfirmationId || state.pendingMutationConfirmation || state.showCommandPalette) {
21757
+ return;
21758
+ }
21759
+ // The `setTimeout` callback is a literal arrow function (not a
21760
+ // string), and the delay is a hard-coded constant, so the
21761
+ // eval-injection vector behind DevSkim DS172411 doesn't apply here.
21762
+ // DevSkim: ignore DS172411
21763
+ const handle = setTimeout(() => {
21764
+ if (mountedRef.current) {
21765
+ dispatch({ type: 'setStatus', value: undefined });
21766
+ }
21767
+ }, 4000);
21768
+ return () => clearTimeout(handle);
21769
+ }, [
21770
+ dispatch,
21771
+ state.inputPrompt,
21772
+ state.pendingConfirmationId,
21773
+ state.pendingMutationConfirmation,
21774
+ state.showCommandPalette,
21775
+ state.statusMessage,
21776
+ ]);
21140
21777
  const refreshContext = React.useCallback(async (options = {}) => {
21141
21778
  // Loud refresh (manual `r`): flip everything to 'loading' so the user
21142
21779
  // sees the surfaces clear, then settle to 'ready' on completion.
@@ -21216,6 +21853,61 @@ function LogInkApp(deps) {
21216
21853
  watcher?.close();
21217
21854
  };
21218
21855
  }, [git, refreshContext, refreshWorktreeContext]);
21856
+ // Per-repo sidebar tab persistence (#21). Resolve the repo root, look
21857
+ // up the cached tab, and dispatch `restoreSidebarTab` once on mount so
21858
+ // the user lands on whichever tab they were last on for this project.
21859
+ // `restoreSidebarTab` (vs `setSidebarTab`) intentionally does not pull
21860
+ // focus into the sidebar — the user lands on commits, the saved tab
21861
+ // is just visible in the gutter.
21862
+ //
21863
+ // The save effect listens to `userSidebarTab` (the user's explicit
21864
+ // choice mirror), not `sidebarTab`. That way the auto-switch to
21865
+ // Branches when entering compose / status doesn't overwrite the saved
21866
+ // preference.
21867
+ const repoRootRef = React.useRef(undefined);
21868
+ React.useEffect(() => {
21869
+ let cancelled = false;
21870
+ void (async () => {
21871
+ try {
21872
+ const repoRoot = (await git.revparse(['--show-toplevel'])).trim();
21873
+ if (cancelled || !repoRoot)
21874
+ return;
21875
+ repoRootRef.current = repoRoot;
21876
+ const saved = getSavedSidebarTab(repoRoot);
21877
+ if (saved && saved !== state.userSidebarTab) {
21878
+ dispatch({ type: 'restoreSidebarTab', value: saved });
21879
+ }
21880
+ }
21881
+ catch {
21882
+ // Not in a worktree, or revparse failed; nothing to restore.
21883
+ }
21884
+ })();
21885
+ return () => { cancelled = true; };
21886
+ }, [git, dispatch]);
21887
+ React.useEffect(() => {
21888
+ const repoRoot = repoRootRef.current;
21889
+ if (!repoRoot)
21890
+ return;
21891
+ saveSidebarTab(repoRoot, state.userSidebarTab);
21892
+ }, [state.userSidebarTab]);
21893
+ // P-stash-explorer: load `git stash show -p <ref>` once the diff view
21894
+ // becomes active with diffSource='stash'. Best-effort — empty stashes
21895
+ // or read errors fall through to a "no diff" hint at the render site.
21896
+ React.useEffect(() => {
21897
+ if (state.activeView !== 'diff' || state.diffSource !== 'stash' || !state.stashDiffRef) {
21898
+ return;
21899
+ }
21900
+ let active = true;
21901
+ setStashDiffLoading(true);
21902
+ void (async () => {
21903
+ const lines = await safe(getStashDiff(git, state.stashDiffRef));
21904
+ if (active) {
21905
+ setStashDiffLines(lines || []);
21906
+ setStashDiffLoading(false);
21907
+ }
21908
+ })();
21909
+ return () => { active = false; };
21910
+ }, [git, state.activeView, state.diffSource, state.stashDiffRef]);
21219
21911
  React.useEffect(() => {
21220
21912
  let active = true;
21221
21913
  async function loadWorktreeHunks() {
@@ -21426,13 +22118,96 @@ function LogInkApp(deps) {
21426
22118
  });
21427
22119
  dispatch({ type: 'setStatus', value: result.message });
21428
22120
  }, [dispatch]);
22121
+ // Open a file in $EDITOR (or $VISUAL) by suspending Ink's hold on the
22122
+ // terminal, spawning the editor synchronously inheriting stdio, then
22123
+ // restoring the alt screen + raw mode and forcing a re-render. The
22124
+ // dance mirrors the SIGTSTP / SIGCONT path in inkTerminalLifecycle.
22125
+ // Falls back to vi when neither env var is set; surfaces a status
22126
+ // message on missing-binary / non-zero exit so the user isn't left
22127
+ // wondering.
22128
+ const openInEditor = React.useCallback((path) => {
22129
+ if (!path)
22130
+ return;
22131
+ const editorEnv = process.env.VISUAL || process.env.EDITOR || 'vi';
22132
+ // $VISUAL / $EDITOR commonly include flags (`code -w`, `vim -f`,
22133
+ // `emacs -nw`). Tokenize on whitespace so the leading word is the
22134
+ // executable and the rest are passed as arguments — passing the
22135
+ // full string to spawnSync as the executable would fail with
22136
+ // ENOENT for any of those configurations.
22137
+ const editorArgs = editorEnv.trim().split(/\s+/).filter(Boolean);
22138
+ const editor = editorArgs[0] || 'vi';
22139
+ const editorPrefixArgs = editorArgs.slice(1);
22140
+ const out = process.stdout;
22141
+ const stdin = process.stdin;
22142
+ const ENTER_ALT = '\x1b[?1049h';
22143
+ const EXIT_ALT = '\x1b[?1049l';
22144
+ const SHOW_CURSOR = '\x1b[?25h';
22145
+ const HIDE_CURSOR = '\x1b[?25l';
22146
+ try {
22147
+ // Drop into the primary buffer + cooked mode so the editor
22148
+ // doesn't inherit our raw-mode keystrokes.
22149
+ stdin.setRawMode?.(false);
22150
+ out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
22151
+ const result = spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
22152
+ if (result.error) {
22153
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
22154
+ }
22155
+ else if (result.signal) {
22156
+ // Editor was killed by a signal (e.g. ^C, SIGTERM). status is
22157
+ // null in this case, so the old `status !== 0` check would
22158
+ // mistakenly fall through to the success branch.
22159
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
22160
+ }
22161
+ else if (typeof result.status === 'number' && result.status !== 0) {
22162
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
22163
+ }
22164
+ else {
22165
+ dispatch({ type: 'setStatus', value: `Edited ${path}` });
22166
+ }
22167
+ }
22168
+ finally {
22169
+ // Re-enter the alt screen + raw mode + hidden cursor; nudge React
22170
+ // so the freshly-restored screen actually paints.
22171
+ out.write(`${ENTER_ALT}${HIDE_CURSOR}`);
22172
+ stdin.setRawMode?.(true);
22173
+ resumeRef?.current?.();
22174
+ }
22175
+ // Worktree status may have changed (e.g. user saved an edit) — silent
22176
+ // refresh so the file row reflects the new staged/unstaged state.
22177
+ void refreshWorktreeContext({ silent: true });
22178
+ }, [dispatch, refreshWorktreeContext, resumeRef]);
21429
22179
  // Resolve the destructive-action target from the live filtered+sorted
21430
22180
  // list the user is looking at, run the action against it, surface the
21431
22181
  // result on the status line, and silently refresh so the deleted item
21432
22182
  // disappears. Called from the y-confirm path for delete-branch / delete-
21433
22183
  // tag / drop-stash / remove-worktree / abort-operation.
21434
- const runWorkflowAction = React.useCallback(async (id) => {
22184
+ const runWorkflowAction = React.useCallback(async (id, payload) => {
21435
22185
  const handlers = {
22186
+ 'create-branch': async () => {
22187
+ const name = payload?.trim();
22188
+ if (!name)
22189
+ return { ok: false, message: 'Branch name required' };
22190
+ const startPoint = context.branches?.currentBranch || 'HEAD';
22191
+ return createBranch(git, name, startPoint);
22192
+ },
22193
+ 'create-tag': async () => {
22194
+ const name = payload?.trim();
22195
+ if (!name)
22196
+ return { ok: false, message: 'Tag name required' };
22197
+ return createLightweightTag(git, name, 'HEAD');
22198
+ },
22199
+ 'checkout-branch': async () => {
22200
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
22201
+ const visible = state.filter
22202
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
22203
+ : all;
22204
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
22205
+ if (!branch)
22206
+ return { ok: false, message: 'No branch selected' };
22207
+ if (branch.current)
22208
+ return { ok: true, message: `Already on ${branch.shortName}` };
22209
+ return checkoutBranch(git, branch);
22210
+ },
21436
22211
  'delete-branch': async () => {
21437
22212
  const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
21438
22213
  const visible = state.filter
@@ -21453,6 +22228,16 @@ function LogInkApp(deps) {
21453
22228
  return { ok: false, message: 'No tag selected' };
21454
22229
  return deleteLocalTag(git, tag.name);
21455
22230
  },
22231
+ 'push-tag': async () => {
22232
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
22233
+ const visible = state.filter
22234
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
22235
+ : all;
22236
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
22237
+ if (!tag)
22238
+ return { ok: false, message: 'No tag selected' };
22239
+ return pushTag(git, tag.name);
22240
+ },
21456
22241
  'drop-stash': async () => {
21457
22242
  const all = context.stashes?.stashes || [];
21458
22243
  const visible = state.filter
@@ -21463,14 +22248,82 @@ function LogInkApp(deps) {
21463
22248
  return { ok: false, message: 'No stash selected' };
21464
22249
  return dropStash(git, stash);
21465
22250
  },
22251
+ 'apply-stash': async () => {
22252
+ const all = context.stashes?.stashes || [];
22253
+ const visible = state.filter
22254
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
22255
+ : all;
22256
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
22257
+ if (!stash)
22258
+ return { ok: false, message: 'No stash selected' };
22259
+ return applyStash(git, stash);
22260
+ },
22261
+ 'pop-stash': async () => {
22262
+ const all = context.stashes?.stashes || [];
22263
+ const visible = state.filter
22264
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
22265
+ : all;
22266
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
22267
+ if (!stash)
22268
+ return { ok: false, message: 'No stash selected' };
22269
+ return popStash(git, stash);
22270
+ },
22271
+ 'checkout-file-from-stash': async () => {
22272
+ const path = payload?.trim();
22273
+ const ref = state.stashDiffRef;
22274
+ if (!path)
22275
+ return { ok: false, message: 'No stash file under cursor' };
22276
+ if (!ref)
22277
+ return { ok: false, message: 'No stash ref active' };
22278
+ return checkoutFileFromStash(git, ref, path);
22279
+ },
22280
+ 'cherry-pick-commit': async () => {
22281
+ const commit = getSelectedInkCommit(state);
22282
+ if (!commit)
22283
+ return { ok: false, message: 'No commit selected' };
22284
+ return cherryPickCommit(git, {
22285
+ hash: commit.hash,
22286
+ shortHash: commit.shortHash,
22287
+ message: commit.message,
22288
+ });
22289
+ },
22290
+ 'checkout-file-from-commit': async () => {
22291
+ // payload is "<sha> <path>" so we pass both through a single
22292
+ // string field on the action.
22293
+ const trimmed = payload?.trim();
22294
+ if (!trimmed)
22295
+ return { ok: false, message: 'No commit file under cursor' };
22296
+ const spaceIndex = trimmed.indexOf(' ');
22297
+ if (spaceIndex < 0)
22298
+ return { ok: false, message: 'Malformed commit file payload' };
22299
+ const sha = trimmed.slice(0, spaceIndex);
22300
+ const path = trimmed.slice(spaceIndex + 1);
22301
+ if (!sha || !path)
22302
+ return { ok: false, message: 'No commit file under cursor' };
22303
+ return checkoutFileFromCommit(git, sha, path);
22304
+ },
21466
22305
  'remove-worktree': async () => {
21467
22306
  const all = context.worktreeList?.worktrees || [];
21468
- // No dedicated cursor for the worktrees tab yet operate on the
21469
- // first non-current worktree as a safe default.
21470
- const target = all.find((w) => !w.current);
21471
- if (!target)
21472
- return { ok: false, message: 'No removable worktree' };
21473
- return removeWorktree(git, target);
22307
+ // Resolve the target from the visible (filtered) list so a
22308
+ // hidden filtered-out worktree can never be the action target.
22309
+ // Falls back to the cursor against the unfiltered list when the
22310
+ // action is invoked from the palette without ever visiting the
22311
+ // worktrees view.
22312
+ const visible = state.filter
22313
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
22314
+ : all;
22315
+ const cursorTarget = visible.length
22316
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
22317
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
22318
+ if (!cursorTarget)
22319
+ return { ok: false, message: 'No worktree selected' };
22320
+ if (cursorTarget.current) {
22321
+ return {
22322
+ ok: false,
22323
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
22324
+ };
22325
+ }
22326
+ return removeWorktree(git, cursorTarget);
21474
22327
  },
21475
22328
  'abort-operation': async () => {
21476
22329
  const operation = context.operation?.operation;
@@ -21479,6 +22332,64 @@ function LogInkApp(deps) {
21479
22332
  }
21480
22333
  return abortOperation(git, operation);
21481
22334
  },
22335
+ 'open-pr': async () => {
22336
+ const repo = context.provider?.repository;
22337
+ if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
22338
+ return { ok: false, message: 'No GitHub remote detected for this repo' };
22339
+ }
22340
+ const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
22341
+ if (pr) {
22342
+ return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
22343
+ }
22344
+ // No PR — fall back to opening the repo page so the user can
22345
+ // create one or browse from there.
22346
+ return openProviderUrl(repo, { type: 'repo' });
22347
+ },
22348
+ 'fetch-remotes': async () => fetchRemotes(git),
22349
+ 'pull-current-branch': async () => pullCurrentBranch(git),
22350
+ 'push-current-branch': async () => pushCurrentBranch(git),
22351
+ 'rename-branch': async () => {
22352
+ const newName = payload?.trim();
22353
+ if (!newName)
22354
+ return { ok: false, message: 'New branch name required' };
22355
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
22356
+ const visible = state.filter
22357
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
22358
+ : all;
22359
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
22360
+ if (!branch)
22361
+ return { ok: false, message: 'No branch selected' };
22362
+ return renameBranch(git, branch.shortName, newName);
22363
+ },
22364
+ 'set-upstream': async () => {
22365
+ const upstream = payload?.trim();
22366
+ if (!upstream)
22367
+ return { ok: false, message: 'Upstream ref required' };
22368
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
22369
+ const visible = state.filter
22370
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
22371
+ : all;
22372
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
22373
+ if (!branch)
22374
+ return { ok: false, message: 'No branch selected' };
22375
+ return setUpstream(git, branch.shortName, upstream);
22376
+ },
22377
+ 'delete-remote-tag': async () => {
22378
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
22379
+ const visible = state.filter
22380
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
22381
+ : all;
22382
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
22383
+ if (!tag)
22384
+ return { ok: false, message: 'No tag selected' };
22385
+ return deleteRemoteTag(git, tag.name);
22386
+ },
22387
+ 'create-stash': async () => {
22388
+ const message = payload?.trim();
22389
+ if (!message)
22390
+ return { ok: false, message: 'Stash message required' };
22391
+ return createStash(git, message);
22392
+ },
21482
22393
  };
21483
22394
  const handler = handlers[id];
21484
22395
  if (!handler) {
@@ -21491,7 +22402,8 @@ function LogInkApp(deps) {
21491
22402
  // flickering the surfaces through a 'loading' phase.
21492
22403
  await refreshContext({ silent: true });
21493
22404
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
21494
- state.selectedStashIndex, state.selectedTagIndex, state.tagSort]);
22405
+ state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
22406
+ state.tagSort]);
21495
22407
  React.useEffect(() => {
21496
22408
  let active = true;
21497
22409
  async function loadPreview() {
@@ -21597,14 +22509,53 @@ function LogInkApp(deps) {
21597
22509
  .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
21598
22510
  .length
21599
22511
  : context.tags?.tags.length;
21600
- const stashVisibleCount = state.filter
22512
+ const visibleStashes = state.filter
21601
22513
  ? (context.stashes?.stashes || [])
21602
22514
  .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
22515
+ : (context.stashes?.stashes || []);
22516
+ const stashVisibleCount = visibleStashes.length;
22517
+ const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
22518
+ // The worktrees promoted view is filterable; mirror the branches /
22519
+ // tags / stash pattern and feed the filtered count into the input
22520
+ // dispatcher so ↑/↓ stay synchronized with the visible rows.
22521
+ const worktreeVisibleCount = state.filter
22522
+ ? (context.worktreeList?.worktrees || [])
22523
+ .filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
21603
22524
  .length
21604
- : context.stashes?.stashes.length;
22525
+ : context.worktreeList?.worktrees.length;
22526
+ // When the diff view is showing a stash patch, swap the previewLineCount
22527
+ // to the stash diff length so the existing pageDetailPreview path
22528
+ // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
22529
+ const diffPreviewLineCount = state.diffSource === 'stash'
22530
+ ? stashDiffLines?.length
22531
+ : filePreview?.hunks.length;
22532
+ // Parse the active stash diff into per-file sections so `]`/`[` can
22533
+ // jump between files and `c` knows which path the cursor is on for
22534
+ // a file-level cherry-pick.
22535
+ const stashDiffFiles = state.diffSource === 'stash' && stashDiffLines
22536
+ ? parseStashDiffFiles(stashDiffLines)
22537
+ : [];
22538
+ const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
22539
+ const stashDiffSelectedPath = (() => {
22540
+ if (state.diffSource !== 'stash' || stashDiffFiles.length === 0)
22541
+ return undefined;
22542
+ const offset = state.diffPreviewOffset;
22543
+ // Walk backwards to the most recent file header at or before the
22544
+ // current cursor offset.
22545
+ let current = stashDiffFiles[0];
22546
+ for (const file of stashDiffFiles) {
22547
+ if (file.startLine <= offset) {
22548
+ current = file;
22549
+ }
22550
+ else {
22551
+ break;
22552
+ }
22553
+ }
22554
+ return current.path;
22555
+ })();
21605
22556
  getLogInkInputEvents(state, inputValue, key, {
21606
22557
  detailFileCount: detail?.files.length,
21607
- previewLineCount: filePreview?.hunks.length,
22558
+ previewLineCount: diffPreviewLineCount,
21608
22559
  worktreeDiffLineCount: worktreeDiff?.lines.length,
21609
22560
  worktreeFileCount: context.worktree?.files.length,
21610
22561
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
@@ -21612,6 +22563,17 @@ function LogInkApp(deps) {
21612
22563
  branchCount: branchVisibleCount,
21613
22564
  tagCount: tagVisibleCount,
21614
22565
  stashCount: stashVisibleCount,
22566
+ stashSelectedRef,
22567
+ stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
22568
+ stashDiffSelectedPath,
22569
+ worktreeListCount: worktreeVisibleCount,
22570
+ worktreeSelectedPath: context.worktree?.files[state.selectedWorktreeFileIndex]?.path,
22571
+ commitDiffSelectedPath: state.diffSource === 'commit'
22572
+ ? selectedDetailFile?.path
22573
+ : undefined,
22574
+ commitDiffSelectedSha: state.diffSource === 'commit'
22575
+ ? selected?.hash
22576
+ : undefined,
21615
22577
  worktreeDirty,
21616
22578
  }).forEach((event) => {
21617
22579
  if (event.type === 'exit') {
@@ -21639,7 +22601,10 @@ function LogInkApp(deps) {
21639
22601
  void runAiCommitDraft();
21640
22602
  }
21641
22603
  else if (event.type === 'runWorkflowAction') {
21642
- void runWorkflowAction(event.id);
22604
+ void runWorkflowAction(event.id, event.payload);
22605
+ }
22606
+ else if (event.type === 'openFileInEditor') {
22607
+ openInEditor(event.path);
21643
22608
  }
21644
22609
  else {
21645
22610
  // P4.5: enrich filter-mutating actions with a precomputed
@@ -21653,6 +22618,13 @@ function LogInkApp(deps) {
21653
22618
  }
21654
22619
  });
21655
22620
  });
22621
+ // Layout depends on focus (sidebar grows when focused), so it's
22622
+ // computed here — after state is in scope but before the render path.
22623
+ const layout = getLogInkLayout({
22624
+ columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
22625
+ rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
22626
+ sidebarFocused: state.focus === 'sidebar',
22627
+ });
21656
22628
  if (layout.tooSmall) {
21657
22629
  return h(Box, {
21658
22630
  flexDirection: 'column',
@@ -21666,7 +22638,7 @@ function LogInkApp(deps) {
21666
22638
  if (showOnboarding) {
21667
22639
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
21668
22640
  }
21669
- 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, 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, theme, idleTip));
22641
+ 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, theme, idleTip));
21670
22642
  }
21671
22643
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
21672
22644
  const { Box, Text } = components;
@@ -21722,28 +22694,96 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
21722
22694
  function renderSidebar(h, components, state, context, contextStatus, width, theme) {
21723
22695
  const { Box, Text } = components;
21724
22696
  const focused = state.focus === 'sidebar';
21725
- const lines = sidebarLines(context, contextStatus, state.sidebarTab, width - 4, state, theme);
21726
22697
  const tabs = getLogInkSidebarTabs();
22698
+ // Accordion layout — every tab's title is visible on its own line, but
22699
+ // only the active tab expands its content underneath. Switching tabs
22700
+ // (1-5 / [/]) collapses the previous and expands the next.
22701
+ const tabBlocks = tabs.flatMap((tab, tabIndex) => {
22702
+ const isActive = tab === state.sidebarTab;
22703
+ const count = sidebarTabCount(tab, context);
22704
+ const labelWithCount = count !== undefined
22705
+ ? `${sidebarTabLabel(tab)} (${count})`
22706
+ : sidebarTabLabel(tab);
22707
+ const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
22708
+ const blocks = [];
22709
+ if (tabIndex > 0) {
22710
+ blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
22711
+ }
22712
+ blocks.push(h(Text, {
22713
+ key: `tab-header-${tab}`,
22714
+ bold: isActive,
22715
+ dimColor: !isActive,
22716
+ }, headerText));
22717
+ if (isActive) {
22718
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
22719
+ }
22720
+ return blocks;
22721
+ });
21727
22722
  return h(Box, {
21728
22723
  borderColor: focusBorderColor(theme, focused),
21729
22724
  borderStyle: theme.borderStyle,
21730
22725
  flexDirection: 'column',
21731
22726
  width,
21732
22727
  paddingX: 1,
21733
- }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, { dimColor: true }, tabs.map((tab) => {
21734
- const count = sidebarTabCount(tab, context);
21735
- const labelWithCount = count !== undefined
21736
- ? `${sidebarTabLabel(tab)} (${count})`
21737
- : sidebarTabLabel(tab);
21738
- return tab === state.sidebarTab ? `[${labelWithCount}]` : labelWithCount;
21739
- }).join(' ')), h(Text, undefined, ''), ...lines.map((line, index) => h(Text, { key: `sidebar-${index}` }, truncate$1(line, width - 4))));
22728
+ }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, undefined, ''), ...tabBlocks);
22729
+ }
22730
+ /**
22731
+ * Render the indented body of the active sidebar tab. The status tab
22732
+ * colours its summary counts (warning / danger / muted) and per-file
22733
+ * rows so they read as the same severity scale used in the main status
22734
+ * surface; every other tab falls through to `sidebarLines` for its
22735
+ * string-based summary.
22736
+ */
22737
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
22738
+ if (tab === 'status') {
22739
+ return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
22740
+ }
22741
+ const lines = sidebarLines(context, contextStatus, tab, width - 6, state, theme);
22742
+ return lines.map((line, index) => h(Text, {
22743
+ key: `tab-content-${tab}-${index}`,
22744
+ dimColor: !line.trim(),
22745
+ }, truncate$1(` ${line}`, width - 4)));
22746
+ }
22747
+ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
22748
+ if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
22749
+ return [h(Text, { key: 'tab-status-loading', dimColor: true }, ' Loading status…')];
22750
+ }
22751
+ const worktree = context.worktree;
22752
+ if (!worktree) {
22753
+ return [h(Text, { key: 'tab-status-empty', dimColor: true }, ' Status unavailable')];
22754
+ }
22755
+ const colorOf = (state) => {
22756
+ if (theme.noColor)
22757
+ return undefined;
22758
+ if (state === 'staged')
22759
+ return theme.colors.warning;
22760
+ if (state === 'unstaged')
22761
+ return theme.colors.danger;
22762
+ return theme.colors.muted;
22763
+ };
22764
+ const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
22765
+ const fileRows = worktree.files.slice(0, 12).map((file, index) => {
22766
+ const codes = `${file.indexStatus}${file.worktreeStatus}`;
22767
+ return h(Text, {
22768
+ key: `tab-status-file-${index}`,
22769
+ color: colorOf(file.state),
22770
+ }, truncate$1(` ${codes} ${file.path}`, width - 4));
22771
+ });
22772
+ return [
22773
+ summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
22774
+ summaryRow(worktree.unstagedCount, 'unstaged', 'tab-status-unstaged', 'unstaged'),
22775
+ summaryRow(worktree.untrackedCount, 'untracked', 'tab-status-untracked', 'untracked'),
22776
+ ...(fileRows.length
22777
+ ? [h(Text, { key: 'tab-status-spacer' }, ''), ...fileRows]
22778
+ : []),
22779
+ ];
21740
22780
  }
21741
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
22781
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
21742
22782
  if (state.activeView === 'status') {
21743
22783
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
21744
22784
  }
21745
22785
  if (state.activeView === 'diff') {
21746
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme);
22786
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
21747
22787
  }
21748
22788
  if (state.activeView === 'compose') {
21749
22789
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -21757,6 +22797,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
21757
22797
  if (state.activeView === 'stash') {
21758
22798
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
21759
22799
  }
22800
+ if (state.activeView === 'worktrees') {
22801
+ return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
22802
+ }
21760
22803
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
21761
22804
  }
21762
22805
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -21966,15 +23009,25 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
21966
23009
  key: `compose-body-${index}`,
21967
23010
  dimColor: line === '<empty>',
21968
23011
  }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
21969
- }), h(Text, undefined, ''), ...(compose.loading
21970
- ? [h(Text, {
23012
+ }),
23013
+ // Loading indicator + post-action message belong inline with the draft
23014
+ // (they describe what just happened to the fields above). The state-
23015
+ // line ("Editing — Enter switches summary↔body…" / "Press e to edit
23016
+ // …") is footer-style guidance and now sits at the very bottom of the
23017
+ // pane so it doesn't visually separate the body from any
23018
+ // result/details.
23019
+ ...(compose.loading
23020
+ ? [
23021
+ h(Text, undefined, ''),
23022
+ h(Text, {
21971
23023
  key: 'compose-loading',
21972
23024
  bold: true,
21973
23025
  color: theme.noColor ? undefined : theme.colors.accent,
21974
23026
  }, theme.ascii
21975
23027
  ? '[...] Generating AI commit draft (this can take a moment)'
21976
- : '⏳ Generating AI commit draft… (this can take a moment)')]
21977
- : [h(Text, { dimColor: true }, stateLine)]), ...(compose.message ? [h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
23028
+ : '⏳ Generating AI commit draft… (this can take a moment)'),
23029
+ ]
23030
+ : []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
21978
23031
  key: `compose-detail-${index}`,
21979
23032
  dimColor: true,
21980
23033
  }, truncate$1(` ${line}`, 140))), ...(!hasStagedFiles && noStagedHint
@@ -21982,7 +23035,7 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
21982
23035
  h(Text, { key: 'compose-no-staged-spacer' }, ''),
21983
23036
  h(Text, { key: 'compose-no-staged', dimColor: true }, truncate$1(noStagedHint, 140)),
21984
23037
  ]
21985
- : []));
23038
+ : []), h(Box, { flexGrow: 1 }), h(Text, { key: 'compose-stateline', dimColor: true }, truncate$1(stateLine, width - 4)));
21986
23039
  }
21987
23040
  function matchesPromotedFilter(haystacks, filter) {
21988
23041
  if (!filter.trim()) {
@@ -22136,6 +23189,48 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
22136
23189
  width,
22137
23190
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
22138
23191
  }
23192
+ function renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
23193
+ const { Box, Text } = components;
23194
+ const focused = state.focus === 'commits';
23195
+ const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
23196
+ const allWorktrees = context.worktreeList?.worktrees || [];
23197
+ const worktrees = state.filter
23198
+ ? allWorktrees.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || '', entry.head || ''], state.filter))
23199
+ : allWorktrees;
23200
+ const selected = Math.max(0, Math.min(state.selectedWorktreeListIndex, Math.max(0, worktrees.length - 1)));
23201
+ const listRows = Math.max(4, bodyRows - 4);
23202
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
23203
+ const visible = worktrees.slice(startIndex, startIndex + listRows);
23204
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
23205
+ const headerRight = loading
23206
+ ? 'loading worktrees'
23207
+ : `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
23208
+ const lines = loading
23209
+ ? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
23210
+ : worktrees.length === 0
23211
+ ? [h(Text, { key: 'worktrees-empty', dimColor: true }, 'No linked worktrees.')]
23212
+ : visible.map((entry, offset) => {
23213
+ const index = startIndex + offset;
23214
+ const isSelected = index === selected;
23215
+ const cursor = isSelected ? '>' : ' ';
23216
+ const marker = entry.current ? '*' : ' ';
23217
+ const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
23218
+ const stateLabel = entry.dirty ? 'dirty' : 'clean';
23219
+ return h(Text, {
23220
+ key: `worktree-${index}`,
23221
+ bold: isSelected,
23222
+ dimColor: !isSelected && !entry.current,
23223
+ }, truncate$1(`${cursor} ${marker} ${branchLabel.padEnd(28)} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
23224
+ });
23225
+ return h(Box, {
23226
+ borderColor: focusBorderColor(theme, focused),
23227
+ borderStyle: theme.borderStyle,
23228
+ flexDirection: 'column',
23229
+ flexShrink: 0,
23230
+ paddingX: 1,
23231
+ width,
23232
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23233
+ }
22139
23234
  /**
22140
23235
  * Filter input cursor for the promoted views (branches/tags/stash).
22141
23236
  * History already shows the same `filter: foo_` affordance in its header
@@ -22154,12 +23249,67 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
22154
23249
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
22155
23250
  ];
22156
23251
  }
22157
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme) {
23252
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
22158
23253
  const { Box, Text } = components;
22159
23254
  const focused = state.focus === 'commits';
22160
23255
  const worktree = context.worktree;
22161
23256
  const worktreeFile = worktree?.files[state.selectedWorktreeFileIndex];
22162
23257
  const visibleRows = Math.max(4, bodyRows - 4);
23258
+ // Stash diff branch: when the user opened the diff via Enter on a stash
23259
+ // row, render the stash patch text directly. The patch is parsed into
23260
+ // per-file sections so `]` / `[` jumps between files and `c`
23261
+ // cherry-picks the file at the cursor.
23262
+ if (state.diffSource === 'stash') {
23263
+ const lines = stashDiffLines || [];
23264
+ const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23265
+ const stashFiles = parseStashDiffFiles(lines);
23266
+ const fileCount = stashFiles.length;
23267
+ const currentFile = (() => {
23268
+ if (fileCount === 0)
23269
+ return undefined;
23270
+ let current = stashFiles[0];
23271
+ for (const file of stashFiles) {
23272
+ if (file.startLine <= state.diffPreviewOffset) {
23273
+ current = file;
23274
+ }
23275
+ else {
23276
+ break;
23277
+ }
23278
+ }
23279
+ return current;
23280
+ })();
23281
+ const currentFileIndex = currentFile
23282
+ ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
23283
+ : -1;
23284
+ const headerLines = stashDiffLoading
23285
+ ? [`Loading diff for ${state.stashDiffRef || 'stash'}...`]
23286
+ : lines.length
23287
+ ? [
23288
+ `Stash: ${state.stashDiffRef || ''}`,
23289
+ fileCount > 0 && currentFile
23290
+ ? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
23291
+ : 'No files in this stash.',
23292
+ `Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
23293
+ '',
23294
+ ]
23295
+ : ['No diff to display for this stash.'];
23296
+ return h(Box, {
23297
+ borderColor: focusBorderColor(theme, focused),
23298
+ borderStyle: theme.borderStyle,
23299
+ flexDirection: 'column',
23300
+ flexShrink: 0,
23301
+ paddingX: 1,
23302
+ width,
23303
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash diff', focused)), h(Text, { dimColor: true }, state.stashDiffRef || 'no stash')), ...headerLines.map((line, index) => h(Text, {
23304
+ key: `stash-diff-header-${index}`,
23305
+ dimColor: index > 0,
23306
+ }, truncate$1(line, width - 4))), ...(stashDiffLoading || !lines.length
23307
+ ? []
23308
+ : visibleLines.map((line, index) => h(Text, {
23309
+ key: `stash-diff-line-${state.diffPreviewOffset + index}`,
23310
+ ...diffLineProps(line, theme),
23311
+ }, truncate$1(line, width - 4)))));
23312
+ }
22163
23313
  // diffSource disambiguates: 'commit' was set when the user opened the
22164
23314
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
22165
23315
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -22253,6 +23403,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
22253
23403
  if (state.showCommandPalette) {
22254
23404
  return renderCommandPalette(h, components, state, width, theme, focused);
22255
23405
  }
23406
+ if (state.inputPrompt) {
23407
+ return renderInputPromptPanel(h, components, state, width, theme, focused);
23408
+ }
22256
23409
  if (state.pendingConfirmationId || state.pendingMutationConfirmation) {
22257
23410
  return renderConfirmationPanel(h, components, state, width, theme, focused);
22258
23411
  }
@@ -22652,16 +23805,40 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
22652
23805
  }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
22653
23806
  key: `commit-header-${index}`,
22654
23807
  dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
22655
- }, truncate$1(line, width - 4))), loading
22656
- ? h(Text, {
22657
- key: 'commit-loading',
22658
- bold: true,
22659
- color: theme.noColor ? undefined : theme.colors.accent,
22660
- }, truncate$1(theme.ascii ? '[...] Generating AI draft' : '⏳ Generating AI draft…', width - 4))
22661
- : h(Text, { key: 'commit-state', dimColor: true }, truncate$1(stateLine, width - 4)), ...trailerLines.map((line, index) => h(Text, {
23808
+ }, truncate$1(line, width - 4))),
23809
+ // Loading indicator + commit result/details stay inline with the body
23810
+ // (they describe what just happened to the fields above). The action
23811
+ // hint ("e edit | c commit | I AI draft") moves to the bottom of the
23812
+ // pane to read as footer guidance, matching the compose surface.
23813
+ ...(loading
23814
+ ? [h(Text, {
23815
+ key: 'commit-loading',
23816
+ bold: true,
23817
+ color: theme.noColor ? undefined : theme.colors.accent,
23818
+ }, truncate$1(theme.ascii ? '[...] Generating AI draft' : '⏳ Generating AI draft…', width - 4))]
23819
+ : []), ...trailerLines.map((line, index) => h(Text, {
22662
23820
  key: `commit-trailer-${index}`,
22663
23821
  dimColor: line.startsWith(' '),
22664
- }, truncate$1(line, width - 4))));
23822
+ }, truncate$1(line, width - 4))), h(Box, { flexGrow: 1 }), loading
23823
+ ? null
23824
+ : h(Text, { key: 'commit-state', dimColor: true }, truncate$1(stateLine, width - 4)));
23825
+ }
23826
+ function renderInputPromptPanel(h, components, state, width, theme, focused) {
23827
+ const { Box, Text } = components;
23828
+ const prompt = state.inputPrompt;
23829
+ if (!prompt) {
23830
+ return h(Box, { width });
23831
+ }
23832
+ return h(Box, {
23833
+ borderColor: focusBorderColor(theme, focused),
23834
+ borderStyle: theme.borderStyle,
23835
+ flexDirection: 'column',
23836
+ width,
23837
+ paddingX: 1,
23838
+ }, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), h(Text, {
23839
+ bold: true,
23840
+ color: theme.noColor ? undefined : theme.colors.accent,
23841
+ }, truncate$1(`${prompt.value}_`, width - 4)), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Enter to submit · Esc to cancel · Ctrl+u to clear'));
22665
23842
  }
22666
23843
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
22667
23844
  const { Box, Text } = components;
@@ -22839,6 +24016,7 @@ function renderFooter(h, components, state, theme, idleTip) {
22839
24016
  const { Box, Text } = components;
22840
24017
  const hints = getLogInkFooterHints({
22841
24018
  activeView: state.activeView,
24019
+ diffSource: state.diffSource,
22842
24020
  filterMode: state.filterMode,
22843
24021
  focus: state.focus,
22844
24022
  pendingKey: state.pendingKey,