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
package/dist/index.js CHANGED
@@ -36,9 +36,11 @@ require('@langchain/core/utils/env');
36
36
  require('@langchain/core/utils/async_caller');
37
37
  var tiktoken = require('tiktoken');
38
38
  var child_process = require('child_process');
39
+ var node_child_process = require('node:child_process');
39
40
  var fs$1 = require('node:fs');
40
41
  var os$1 = require('node:os');
41
42
  var path$1 = require('node:path');
43
+ var crypto = require('node:crypto');
42
44
  var readline = require('readline');
43
45
  var util$1 = require('util');
44
46
  var url = require('url');
@@ -67,6 +69,7 @@ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
67
69
  var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1);
68
70
  var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
69
71
  var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
72
+ var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
70
73
  var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
71
74
 
72
75
  // This file is auto-generated - DO NOT EDIT
@@ -74,7 +77,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
74
77
  /**
75
78
  * Current build version from package.json
76
79
  */
77
- const BUILD_VERSION = "0.35.0";
80
+ const BUILD_VERSION = "0.36.0";
78
81
 
79
82
  const isInteractive = (config) => {
80
83
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -13914,13 +13917,18 @@ function applyCommitComposeAction(state, action) {
13914
13917
  loading: action.value,
13915
13918
  };
13916
13919
  case 'setDraft':
13920
+ // No `message` here — the loader → filled fields are the confirmation
13921
+ // that the AI generated something. A lingering "AI draft ready for
13922
+ // editing" line in the panel reads as stale state. The runtime still
13923
+ // posts the same string to the footer status line for transient
13924
+ // feedback.
13917
13925
  return {
13918
13926
  ...state,
13919
13927
  ...splitCommitDraft(action.value),
13920
13928
  field: 'summary',
13921
13929
  editing: true,
13922
13930
  loading: false,
13923
- message: 'AI draft ready for editing',
13931
+ message: undefined,
13924
13932
  details: undefined,
13925
13933
  };
13926
13934
  case 'setResult':
@@ -14552,6 +14560,85 @@ function getLogInkWorkflowActions() {
14552
14560
  kind: 'normal',
14553
14561
  requiresConfirmation: false,
14554
14562
  },
14563
+ {
14564
+ // Per-view-only: scoped to the history view in inkInput so `c`
14565
+ // doesn't fire elsewhere. Empty key keeps it palette-discoverable
14566
+ // without registering a global hotkey.
14567
+ id: 'cherry-pick-commit',
14568
+ key: '',
14569
+ label: 'Cherry-pick commit',
14570
+ description: 'Apply the selected commit on top of the current branch (after confirmation).',
14571
+ kind: 'destructive',
14572
+ requiresConfirmation: true,
14573
+ },
14574
+ {
14575
+ // Per-view-only: scoped to the commit-diff explore in inkInput.
14576
+ // Routed through the y-confirm path because `git checkout <sha> --
14577
+ // <path>` overwrites the worktree file unconditionally and we
14578
+ // want the user to acknowledge that before discarding any local
14579
+ // edits to the path.
14580
+ id: 'checkout-file-from-commit',
14581
+ key: '',
14582
+ label: 'Cherry-pick file from commit',
14583
+ description: 'Materialize the selected file from this commit into the working tree (after confirmation).',
14584
+ kind: 'destructive',
14585
+ requiresConfirmation: true,
14586
+ },
14587
+ {
14588
+ // Per-view-only: scoped to the stash-diff explorer in inkInput.
14589
+ // Same overwrite rationale as `checkout-file-from-commit` — the
14590
+ // y-confirm path is the dirty-tree warning.
14591
+ id: 'checkout-file-from-stash',
14592
+ key: '',
14593
+ label: 'Cherry-pick file from stash',
14594
+ description: 'Materialize the selected file from this stash into the working tree (after confirmation).',
14595
+ kind: 'destructive',
14596
+ requiresConfirmation: true,
14597
+ },
14598
+ {
14599
+ id: 'open-pr',
14600
+ key: 'O',
14601
+ label: 'Open PR / repo',
14602
+ description: 'Open the current branch\'s pull request in the browser, or the repo page if there\'s no PR.',
14603
+ kind: 'normal',
14604
+ requiresConfirmation: false,
14605
+ },
14606
+ {
14607
+ id: 'fetch-remotes',
14608
+ key: 'F',
14609
+ label: 'Fetch all remotes',
14610
+ description: 'Run `git fetch --all --prune` and silently refresh context.',
14611
+ kind: 'normal',
14612
+ requiresConfirmation: false,
14613
+ },
14614
+ {
14615
+ id: 'pull-current-branch',
14616
+ key: 'U',
14617
+ label: 'Pull current branch',
14618
+ description: 'Run `git pull --ff-only` against the current branch.',
14619
+ kind: 'normal',
14620
+ requiresConfirmation: false,
14621
+ },
14622
+ {
14623
+ id: 'push-current-branch',
14624
+ key: 'P',
14625
+ label: 'Push current branch',
14626
+ description: 'Run `git push` for the current branch.',
14627
+ kind: 'normal',
14628
+ requiresConfirmation: false,
14629
+ },
14630
+ {
14631
+ // Per-view-only — the inkInput handler scopes this to the tags
14632
+ // surface so we don't expose `R` as a remote-delete from elsewhere.
14633
+ // The empty `key` keeps the workflow palette-discoverable but does
14634
+ // not register a global hotkey.
14635
+ id: 'delete-remote-tag',
14636
+ key: '',
14637
+ label: 'Delete remote tag',
14638
+ description: 'Push :tag to origin to delete the selected tag remotely after confirmation.',
14639
+ kind: 'destructive',
14640
+ requiresConfirmation: true,
14641
+ },
14555
14642
  {
14556
14643
  id: 'stage-file',
14557
14644
  key: 'space',
@@ -14799,6 +14886,13 @@ const LOG_INK_KEY_BINDINGS = [
14799
14886
  description: 'Push the stash view (gz; gs is reserved for status).',
14800
14887
  contexts: ['normal'],
14801
14888
  },
14889
+ {
14890
+ id: 'navigateWorktrees',
14891
+ keys: ['gw'],
14892
+ label: 'worktrees',
14893
+ description: 'Push the linked worktrees view.',
14894
+ contexts: ['normal'],
14895
+ },
14802
14896
  {
14803
14897
  id: 'navigateBack',
14804
14898
  keys: ['<', 'esc'],
@@ -14919,6 +15013,7 @@ const GLOBAL_BINDING_IDS = [
14919
15013
  'navigateBranches',
14920
15014
  'navigateTags',
14921
15015
  'navigateStash',
15016
+ 'navigateWorktrees',
14922
15017
  'navigateBack',
14923
15018
  ];
14924
15019
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -14997,37 +15092,57 @@ function getLogInkFooterHints(options) {
14997
15092
  };
14998
15093
  }
14999
15094
  if (options.activeView === 'diff') {
15095
+ if (options.diffSource === 'stash') {
15096
+ return {
15097
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'esc back'],
15098
+ global: NORMAL_GLOBAL_HINTS,
15099
+ };
15100
+ }
15101
+ if (options.diffSource === 'commit') {
15102
+ // Commit-diff explore: read-only diff, but `c` cherry-picks the
15103
+ // cursored file from the commit into the worktree.
15104
+ return {
15105
+ contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'esc back'],
15106
+ global: NORMAL_GLOBAL_HINTS,
15107
+ };
15108
+ }
15000
15109
  return {
15001
- contextual: ['j/k hunks', 'space stage', 'z revert', 'e/c compose', 'esc files'],
15110
+ contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'esc files'],
15002
15111
  global: NORMAL_GLOBAL_HINTS,
15003
15112
  };
15004
15113
  }
15005
15114
  if (options.activeView === 'compose') {
15006
15115
  return {
15007
- contextual: ['e edit', 'tab field', 'c commit', 'I AI draft', 'esc back'],
15116
+ contextual: ['e edit', 'c commit', 'I AI draft', 'gs hunks', 'esc back'],
15008
15117
  global: NORMAL_GLOBAL_HINTS,
15009
15118
  };
15010
15119
  }
15011
15120
  if (options.activeView === 'branches') {
15012
15121
  return {
15013
- contextual: ['↑/↓ branches', 's sort', 'D delete', 'X checkout', 'enter diff'],
15122
+ contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort'],
15014
15123
  global: NORMAL_GLOBAL_HINTS,
15015
15124
  };
15016
15125
  }
15017
15126
  if (options.activeView === 'tags') {
15018
15127
  return {
15019
- contextual: ['↑/↓ tags', 's sort', 'T create', 'X push', 'esc back'],
15128
+ contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort'],
15020
15129
  global: NORMAL_GLOBAL_HINTS,
15021
15130
  };
15022
15131
  }
15023
15132
  if (options.activeView === 'stash') {
15024
15133
  return {
15025
- contextual: ['↑/↓ stashes', 'A apply', 'D drop', 'esc back'],
15134
+ contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', 'X drop'],
15135
+ global: NORMAL_GLOBAL_HINTS,
15136
+ };
15137
+ }
15138
+ if (options.activeView === 'worktrees') {
15139
+ return {
15140
+ contextual: ['↑/↓ worktrees', 'W remove', 'esc back'],
15026
15141
  global: NORMAL_GLOBAL_HINTS,
15027
15142
  };
15028
15143
  }
15029
15144
  return {
15030
- contextual: ['↑/↓ move', '/ search', 'gg/G top/bottom', 'n/N next'],
15145
+ contextual: ['↑/↓ move', 'enter diff', 'c cherry-pick', '/ search', 'gg/G top/bottom'],
15031
15146
  global: NORMAL_GLOBAL_HINTS,
15032
15147
  };
15033
15148
  }
@@ -15290,10 +15405,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
15290
15405
  if (command.requiresConfirmation) {
15291
15406
  return [action({ type: 'setPendingConfirmation', value: command.id })];
15292
15407
  }
15293
- return [
15294
- action({ type: 'setWorkflowAction', value: command.id }),
15295
- action({ type: 'setStatus', value: `${command.label} selected` }),
15296
- ];
15408
+ // Non-confirm workflows are dispatched directly through the runtime
15409
+ // workflow runner same path the keyboard takes. Previously this
15410
+ // emitted `setWorkflowAction` only, which set state but never fired
15411
+ // the action because nothing in the runtime consumes
15412
+ // `workflowActionId`.
15413
+ return [{ type: 'runWorkflowAction', id: command.id }];
15297
15414
  }
15298
15415
  // Binding-derived commands. Map each LogInkCommandId to the same events
15299
15416
  // the keystroke would emit. Order matches the keymap registry.
@@ -15355,6 +15472,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
15355
15472
  return [action({ type: 'pushView', value: 'tags' })];
15356
15473
  case 'navigateStash':
15357
15474
  return [action({ type: 'pushView', value: 'stash' })];
15475
+ case 'navigateWorktrees':
15476
+ return [action({ type: 'pushView', value: 'worktrees' })];
15358
15477
  case 'navigateBack':
15359
15478
  return [action({ type: 'popView' })];
15360
15479
  case 'openSelected': {
@@ -15453,6 +15572,39 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15453
15572
  }
15454
15573
  return [{ type: 'exit' }];
15455
15574
  }
15575
+ // Input prompt is the most modal — when active, every keystroke routes
15576
+ // into the prompt until Enter (submit) or Esc (cancel). Sits above the
15577
+ // filter/confirmation/compose handlers so a prompt opened from inside
15578
+ // any of those still captures focus cleanly.
15579
+ if (state.inputPrompt) {
15580
+ if (key.escape) {
15581
+ return [
15582
+ action({ type: 'closeInputPrompt' }),
15583
+ action({ type: 'setStatus', value: 'cancelled' }),
15584
+ ];
15585
+ }
15586
+ if (key.return) {
15587
+ const value = state.inputPrompt.value.trim();
15588
+ if (!value) {
15589
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
15590
+ }
15591
+ const id = state.inputPrompt.kind;
15592
+ return [
15593
+ { type: 'runWorkflowAction', id, payload: value },
15594
+ action({ type: 'closeInputPrompt' }),
15595
+ ];
15596
+ }
15597
+ if (key.backspace || key.delete) {
15598
+ return [action({ type: 'backspaceInputPrompt' })];
15599
+ }
15600
+ if (key.ctrl && inputValue === 'u') {
15601
+ return [action({ type: 'clearInputPromptText' })];
15602
+ }
15603
+ if (inputValue && !key.ctrl && !key.meta) {
15604
+ return [action({ type: 'appendInputPrompt', value: inputValue })];
15605
+ }
15606
+ return [];
15607
+ }
15456
15608
  if (state.commitCompose.editing) {
15457
15609
  if (key.escape) {
15458
15610
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -15521,7 +15673,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15521
15673
  // selected item and run the right action function.
15522
15674
  if (workflowAction) {
15523
15675
  return [
15524
- { type: 'runWorkflowAction', id: workflowAction.id },
15676
+ { type: 'runWorkflowAction', id: workflowAction.id, payload: state.pendingConfirmationPayload },
15525
15677
  action({ type: 'setPendingConfirmation', value: undefined }),
15526
15678
  ];
15527
15679
  }
@@ -15671,6 +15823,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15671
15823
  action({ type: 'setStatus', value: 'jumped to stash' }),
15672
15824
  ];
15673
15825
  }
15826
+ if (state.pendingKey === 'g' && inputValue === 'w') {
15827
+ return [
15828
+ action({ type: 'pushView', value: 'worktrees' }),
15829
+ action({ type: 'setStatus', value: 'jumped to worktrees' }),
15830
+ ];
15831
+ }
15674
15832
  if (inputValue === 'g') {
15675
15833
  if (state.pendingKey === 'g') {
15676
15834
  return [
@@ -15722,6 +15880,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15722
15880
  hunkOffsets: context.worktreeHunkOffsets,
15723
15881
  })];
15724
15882
  }
15883
+ if (state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffFileOffsets?.length) {
15884
+ return [action({
15885
+ type: 'jumpCommitDiffHunk',
15886
+ delta: -1,
15887
+ hunkOffsets: context.stashDiffFileOffsets,
15888
+ })];
15889
+ }
15725
15890
  if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15726
15891
  return [action({
15727
15892
  type: 'jumpCommitDiffHunk',
@@ -15739,6 +15904,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15739
15904
  hunkOffsets: context.worktreeHunkOffsets,
15740
15905
  })];
15741
15906
  }
15907
+ if (state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffFileOffsets?.length) {
15908
+ return [action({
15909
+ type: 'jumpCommitDiffHunk',
15910
+ delta: 1,
15911
+ hunkOffsets: context.stashDiffFileOffsets,
15912
+ })];
15913
+ }
15742
15914
  if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15743
15915
  return [action({
15744
15916
  type: 'jumpCommitDiffHunk',
@@ -15792,6 +15964,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15792
15964
  if (state.activeView === 'stash' && context.stashCount) {
15793
15965
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
15794
15966
  }
15967
+ if (state.activeView === 'worktrees' && context.worktreeListCount) {
15968
+ return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
15969
+ }
15795
15970
  if (state.activeView === 'history' &&
15796
15971
  state.focus === 'commits' &&
15797
15972
  state.selectedIndex === 0 &&
@@ -15842,6 +16017,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15842
16017
  if (state.activeView === 'stash' && context.stashCount) {
15843
16018
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
15844
16019
  }
16020
+ if (state.activeView === 'worktrees' && context.worktreeListCount) {
16021
+ return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
16022
+ }
15845
16023
  return [
15846
16024
  action(state.focus === 'sidebar'
15847
16025
  ? { type: 'nextSidebarTab' }
@@ -15942,12 +16120,183 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15942
16120
  })];
15943
16121
  }
15944
16122
  }
15945
- if (key.return && state.activeView === 'status' && context.worktreeFileCount) {
16123
+ // Enter on a sidebar tab drills into the corresponding promoted view
16124
+ // (status / branches / tags / stash). Sits above the per-view Enter
16125
+ // handlers so a sidebar-focused Enter never fires checkout-branch /
16126
+ // navigateOpenDiffForCommit / etc. against the (hidden) selection in
16127
+ // the active tab.
16128
+ //
16129
+ // The Enter also moves focus out of the sidebar into the newly opened
16130
+ // list — otherwise ↑/↓ keep cycling sidebar tabs instead of navigating
16131
+ // inside the just-opened view, which made the drill-in feel half-done.
16132
+ if (key.return && state.focus === 'sidebar') {
16133
+ const tabToView = {
16134
+ status: 'status',
16135
+ branches: 'branches',
16136
+ tags: 'tags',
16137
+ stashes: 'stash',
16138
+ worktrees: 'worktrees',
16139
+ };
16140
+ const target = tabToView[state.sidebarTab];
16141
+ if (target) {
16142
+ return [
16143
+ action({ type: 'pushView', value: target }),
16144
+ action({ type: 'setFocus', value: 'commits' }),
16145
+ ];
16146
+ }
16147
+ return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
16148
+ }
16149
+ if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
15946
16150
  return [action({
15947
16151
  type: 'navigateOpenDiffForWorktreeFile',
15948
16152
  fileIndex: state.selectedWorktreeFileIndex,
15949
16153
  })];
15950
16154
  }
16155
+ // Enter on a branch row checks the branch out. Non-destructive workflow
16156
+ // action — no confirmation prompt.
16157
+ if (key.return && state.activeView === 'branches' && state.focus === 'commits' && context.branchCount) {
16158
+ return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
16159
+ }
16160
+ // `+` opens a create-branch / create-tag prompt depending on context.
16161
+ // Works from either the matching promoted view (active branches /
16162
+ // tags surface) or from the sidebar when the corresponding tab is
16163
+ // active — saves a drill-in for "I just want to make a new branch".
16164
+ const wantsCreateBranch = inputValue === '+' && (state.activeView === 'branches' ||
16165
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches'));
16166
+ const wantsCreateTag = inputValue === '+' && (state.activeView === 'tags' ||
16167
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags'));
16168
+ if (wantsCreateBranch) {
16169
+ return [action({
16170
+ type: 'openInputPrompt',
16171
+ kind: 'create-branch',
16172
+ label: 'New branch name',
16173
+ })];
16174
+ }
16175
+ if (wantsCreateTag) {
16176
+ return [action({
16177
+ type: 'openInputPrompt',
16178
+ kind: 'create-tag',
16179
+ label: 'New tag name',
16180
+ })];
16181
+ }
16182
+ // Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
16183
+ // then drop). Drop is the existing destructive `X` workflow which
16184
+ // routes through the y-confirm path. Scoped to the stash view so the
16185
+ // letters stay free elsewhere.
16186
+ if (inputValue === 'a' && state.activeView === 'stash' && context.stashCount) {
16187
+ return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
16188
+ }
16189
+ if (inputValue === 'p' && state.activeView === 'stash' && context.stashCount) {
16190
+ return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
16191
+ }
16192
+ // Per-view tag action: `P` pushes the selected tag to origin. Letter
16193
+ // is scoped to the tags surface so it doesn't collide with `p` for
16194
+ // pop-stash. Note: this also takes precedence over the global
16195
+ // push-current-branch workflow's `P` key.
16196
+ if (inputValue === 'P' && state.activeView === 'tags' && context.tagCount) {
16197
+ return [{ type: 'runWorkflowAction', id: 'push-tag' }];
16198
+ }
16199
+ // Per-view branches actions: `R` renames the selected branch, `u`
16200
+ // sets its upstream. Both open the input prompt so the user can type
16201
+ // the new value. Pre-fills are handled by the prompt's `initial`.
16202
+ if (inputValue === 'R' && state.activeView === 'branches' && context.branchCount) {
16203
+ return [action({
16204
+ type: 'openInputPrompt',
16205
+ kind: 'rename-branch',
16206
+ label: 'Rename branch to',
16207
+ })];
16208
+ }
16209
+ if (inputValue === 'u' && state.activeView === 'branches' && context.branchCount) {
16210
+ return [action({
16211
+ type: 'openInputPrompt',
16212
+ kind: 'set-upstream',
16213
+ label: 'Upstream ref (e.g. origin/main)',
16214
+ })];
16215
+ }
16216
+ // Per-view tag action: `R` deletes the tag from the remote (after
16217
+ // confirmation). Scoped per-view so this letter is free elsewhere
16218
+ // (especially the `R` rename binding on the branches view).
16219
+ if (inputValue === 'R' && state.activeView === 'tags' && context.tagCount) {
16220
+ return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
16221
+ }
16222
+ // Global stash hotkey: `S` opens a stash-message prompt and
16223
+ // `createStash` runs once submitted. Available everywhere there's
16224
+ // not a more modal handler in front of it.
16225
+ if (inputValue === 'S') {
16226
+ return [action({
16227
+ type: 'openInputPrompt',
16228
+ kind: 'create-stash',
16229
+ label: 'Stash message',
16230
+ })];
16231
+ }
16232
+ // `o` opens the file under the cursor in $EDITOR. Available on the
16233
+ // status surface (worktree files), the worktree diff (the file being
16234
+ // diffed), and the stash diff (the file the cursor sits in inside
16235
+ // the patch). The runtime suspends Ink, spawns the editor sync, then
16236
+ // re-renders.
16237
+ if (inputValue === 'o' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
16238
+ return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
16239
+ }
16240
+ if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'worktree' && context.worktreeSelectedPath) {
16241
+ return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
16242
+ }
16243
+ if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
16244
+ return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
16245
+ }
16246
+ // `c` on a stash diff cherry-picks the file under the cursor —
16247
+ // materializes that single path from the stash into the working tree
16248
+ // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
16249
+ // path because the checkout overwrites the worktree file
16250
+ // unconditionally; the prompt is the user's chance to abort if they
16251
+ // have unsaved edits at that path.
16252
+ if (inputValue === 'c' &&
16253
+ state.activeView === 'diff' &&
16254
+ state.diffSource === 'stash' &&
16255
+ context.stashDiffSelectedPath &&
16256
+ state.stashDiffRef) {
16257
+ return [action({
16258
+ type: 'setPendingConfirmation',
16259
+ value: 'checkout-file-from-stash',
16260
+ payload: context.stashDiffSelectedPath,
16261
+ })];
16262
+ }
16263
+ // `c` on a commit-diff explore cherry-picks the cursored file from
16264
+ // that historical commit — `git checkout <sha> -- <path>`. Same
16265
+ // confirmation rationale as the stash variant. The payload encodes
16266
+ // both the sha and the path so the runtime handler doesn't have to
16267
+ // re-resolve either.
16268
+ if (inputValue === 'c' &&
16269
+ state.activeView === 'diff' &&
16270
+ state.diffSource === 'commit' &&
16271
+ context.commitDiffSelectedPath &&
16272
+ context.commitDiffSelectedSha) {
16273
+ return [action({
16274
+ type: 'setPendingConfirmation',
16275
+ value: 'checkout-file-from-commit',
16276
+ payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
16277
+ })];
16278
+ }
16279
+ // `c` on the history view cherry-picks the full selected commit on
16280
+ // top of the current branch. Routed through the y-confirm flow since
16281
+ // it can produce conflicts and is a real working-tree mutation.
16282
+ if (inputValue === 'c' &&
16283
+ state.activeView === 'history' &&
16284
+ state.focus === 'commits' &&
16285
+ state.filteredCommits.length > 0 &&
16286
+ !state.pendingCommitFocused) {
16287
+ return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
16288
+ }
16289
+ // Enter on a stash row pushes the diff view scoped to that stash.
16290
+ // The runtime loads `git stash show -p <ref>` once the view is
16291
+ // active. The stash ref is passed via the action so we don't need a
16292
+ // context lookup here.
16293
+ if (key.return && state.activeView === 'stash' && state.focus === 'commits' && context.stashCount && context.stashSelectedRef) {
16294
+ return [action({
16295
+ type: 'navigateOpenDiffForStash',
16296
+ ref: context.stashSelectedRef,
16297
+ stashIndex: state.selectedStashIndex,
16298
+ })];
16299
+ }
15951
16300
  if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
15952
16301
  return [{ type: 'toggleSelectedFileStage' }];
15953
16302
  }
@@ -15983,10 +16332,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
15983
16332
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
15984
16333
  }
15985
16334
  if (workflowAction) {
15986
- return [
15987
- action({ type: 'setWorkflowAction', value: workflowAction.id }),
15988
- action({ type: 'setStatus', value: `${workflowAction.label} selected` }),
15989
- ];
16335
+ // Non-destructive workflow — fire it directly via the runtime
16336
+ // handler. The handler surfaces success/failure on the status line
16337
+ // and silently refreshes context so the list updates.
16338
+ return [{ type: 'runWorkflowAction', id: workflowAction.id }];
15990
16339
  }
15991
16340
  return [];
15992
16341
  }
@@ -16002,7 +16351,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16002
16351
  * fall back to "already seen" so we never block startup.
16003
16352
  */
16004
16353
  const MARKER_BASENAME = 'onboarding.seen';
16005
- function resolveCacheDir() {
16354
+ function resolveCacheDir$1() {
16006
16355
  const xdg = process.env.XDG_CACHE_HOME;
16007
16356
  if (xdg && xdg.trim().length > 0) {
16008
16357
  return path__namespace$1.join(xdg, 'coco');
@@ -16010,7 +16359,7 @@ function resolveCacheDir() {
16010
16359
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
16011
16360
  }
16012
16361
  function getOnboardingMarkerPath() {
16013
- return path__namespace$1.join(resolveCacheDir(), MARKER_BASENAME);
16362
+ return path__namespace$1.join(resolveCacheDir$1(), MARKER_BASENAME);
16014
16363
  }
16015
16364
  function hasSeenOnboarding() {
16016
16365
  try {
@@ -16034,6 +16383,65 @@ function markOnboardingSeen() {
16034
16383
  }
16035
16384
  }
16036
16385
 
16386
+ /**
16387
+ * Persist which sidebar tab the user last had active, keyed per repo so
16388
+ * switching projects doesn't reset every other repo's preference. The
16389
+ * cache lives next to the onboarding marker (XDG-friendly) and is
16390
+ * best-effort: read/write failures fall back to the default sidebar
16391
+ * tab on next start.
16392
+ *
16393
+ * Repos are keyed by a short hash of their absolute path — no PII in
16394
+ * the cache filename, and re-creating a repo at the same path keeps
16395
+ * the same preference.
16396
+ */
16397
+ const VALID_TABS = [
16398
+ 'status',
16399
+ 'branches',
16400
+ 'tags',
16401
+ 'stashes',
16402
+ 'worktrees',
16403
+ ];
16404
+ function resolveCacheDir() {
16405
+ const xdg = process.env.XDG_CACHE_HOME;
16406
+ if (xdg && xdg.trim().length > 0) {
16407
+ return path__namespace$1.join(xdg, 'coco');
16408
+ }
16409
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
16410
+ }
16411
+ function repoKey(repoPath) {
16412
+ // sha1 is used here as a non-security cache-key derivation — we just
16413
+ // need a deterministic short identifier for the marker filename so
16414
+ // re-creating a repo at the same path keeps the same preference.
16415
+ // No PII or auth context is hashed; no collision-resistance against
16416
+ // an adversary is required. DevSkim DS126858 doesn't apply.
16417
+ // DevSkim: ignore DS126858
16418
+ return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
16419
+ }
16420
+ function getSidebarTabMarkerPath(repoPath) {
16421
+ return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
16422
+ }
16423
+ function getSavedSidebarTab(repoPath) {
16424
+ try {
16425
+ const raw = fs__namespace$1.readFileSync(getSidebarTabMarkerPath(repoPath), 'utf8').trim();
16426
+ return VALID_TABS.includes(raw)
16427
+ ? raw
16428
+ : undefined;
16429
+ }
16430
+ catch {
16431
+ return undefined;
16432
+ }
16433
+ }
16434
+ function saveSidebarTab(repoPath, tab) {
16435
+ const marker = getSidebarTabMarkerPath(repoPath);
16436
+ try {
16437
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(marker), { recursive: true });
16438
+ fs__namespace$1.writeFileSync(marker, tab);
16439
+ }
16440
+ catch {
16441
+ // Best-effort persistence; swallow.
16442
+ }
16443
+ }
16444
+
16037
16445
  /**
16038
16446
  * Promoted-view selection rectification on filter changes (P4.5).
16039
16447
  *
@@ -16287,7 +16695,12 @@ function getLogInkLayout(input) {
16287
16695
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
16288
16696
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
16289
16697
  const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
16290
- const sidebarWidth = Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
16698
+ // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
16699
+ // (~36% of width). The transition is instant per render — focus tab to
16700
+ // expand, focus away to collapse.
16701
+ const sidebarWidth = input.sidebarFocused
16702
+ ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
16703
+ : Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
16291
16704
  return {
16292
16705
  bodyRows: Math.max(8, rows - 5),
16293
16706
  columns,
@@ -17027,9 +17440,19 @@ function withPushedView(state, value) {
17027
17440
  ...state,
17028
17441
  activeView: value,
17029
17442
  viewStack,
17443
+ // The compose + status views' right detail panels already show
17444
+ // worktree info, so keeping the left sidebar on the Status tab
17445
+ // duplicates that information. Auto-switch to Branches when entering
17446
+ // either view; the user can swap back with [/] if they want.
17447
+ //
17448
+ // We update only the rendered `sidebarTab` here, never
17449
+ // `userSidebarTab`, so this auto-switch is invisible to per-repo
17450
+ // persistence and pop-view restores the previous tab.
17451
+ sidebarTab: value === 'compose' || value === 'status' ? 'branches' : state.sidebarTab,
17030
17452
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
17031
17453
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17032
17454
  diffSource: value === 'diff' ? state.diffSource : undefined,
17455
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17033
17456
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17034
17457
  pendingKey: undefined,
17035
17458
  };
@@ -17044,9 +17467,14 @@ function withPoppedView(state) {
17044
17467
  ...state,
17045
17468
  activeView: next,
17046
17469
  viewStack,
17470
+ // Restore the user's last explicit tab choice so popping out of
17471
+ // compose / status (which auto-switch the sidebar to Branches)
17472
+ // returns the user to whatever they actually had open before.
17473
+ sidebarTab: state.userSidebarTab,
17047
17474
  worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
17048
17475
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17049
17476
  diffSource: next === 'diff' ? state.diffSource : undefined,
17477
+ stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
17050
17478
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
17051
17479
  pendingKey: undefined,
17052
17480
  };
@@ -17063,6 +17491,7 @@ function withReplacedView(state, value) {
17063
17491
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
17064
17492
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17065
17493
  diffSource: value === 'diff' ? state.diffSource : undefined,
17494
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17066
17495
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17067
17496
  pendingKey: undefined,
17068
17497
  };
@@ -17155,6 +17584,7 @@ function createLogInkState(rows, options = {}) {
17155
17584
  selectedBranchIndex: 0,
17156
17585
  selectedTagIndex: 0,
17157
17586
  selectedStashIndex: 0,
17587
+ selectedWorktreeListIndex: 0,
17158
17588
  branchSort: DEFAULT_BRANCH_SORT_MODE,
17159
17589
  tagSort: DEFAULT_TAG_SORT_MODE,
17160
17590
  paletteFilter: '',
@@ -17170,10 +17600,12 @@ function createLogInkState(rows, options = {}) {
17170
17600
  showCommandPalette: false,
17171
17601
  workflowActionId: undefined,
17172
17602
  pendingConfirmationId: undefined,
17603
+ pendingConfirmationPayload: undefined,
17173
17604
  pendingMutationConfirmation: undefined,
17174
17605
  pendingKey: undefined,
17175
17606
  focus: 'commits',
17176
17607
  sidebarTab: 'status',
17608
+ userSidebarTab: 'status',
17177
17609
  };
17178
17610
  }
17179
17611
  function getSelectedInkCommit(state) {
@@ -17280,6 +17712,12 @@ function applyLogInkAction(state, action) {
17280
17712
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
17281
17713
  pendingKey: undefined,
17282
17714
  };
17715
+ case 'moveWorktreeListEntry':
17716
+ return {
17717
+ ...state,
17718
+ selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
17719
+ pendingKey: undefined,
17720
+ };
17283
17721
  case 'cycleBranchSort':
17284
17722
  return {
17285
17723
  ...state,
@@ -17296,6 +17734,30 @@ function applyLogInkAction(state, action) {
17296
17734
  selectedTagIndex: 0,
17297
17735
  pendingKey: undefined,
17298
17736
  };
17737
+ case 'openInputPrompt':
17738
+ return {
17739
+ ...state,
17740
+ inputPrompt: {
17741
+ kind: action.kind,
17742
+ label: action.label,
17743
+ value: action.initial || '',
17744
+ },
17745
+ pendingKey: undefined,
17746
+ };
17747
+ case 'appendInputPrompt':
17748
+ return state.inputPrompt
17749
+ ? { ...state, inputPrompt: { ...state.inputPrompt, value: `${state.inputPrompt.value}${action.value}` } }
17750
+ : state;
17751
+ case 'backspaceInputPrompt':
17752
+ return state.inputPrompt
17753
+ ? { ...state, inputPrompt: { ...state.inputPrompt, value: state.inputPrompt.value.slice(0, -1) } }
17754
+ : state;
17755
+ case 'clearInputPromptText':
17756
+ return state.inputPrompt
17757
+ ? { ...state, inputPrompt: { ...state.inputPrompt, value: '' } }
17758
+ : state;
17759
+ case 'closeInputPrompt':
17760
+ return { ...state, inputPrompt: undefined, pendingKey: undefined };
17299
17761
  case 'moveToBottom':
17300
17762
  return {
17301
17763
  ...state,
@@ -17314,12 +17776,15 @@ function applyLogInkAction(state, action) {
17314
17776
  pendingCommitFocused: false,
17315
17777
  pendingKey: undefined,
17316
17778
  };
17317
- case 'nextSidebarTab':
17779
+ case 'nextSidebarTab': {
17780
+ const next = cycleValue(SIDEBAR_TABS, state.sidebarTab, 1);
17318
17781
  return {
17319
17782
  ...state,
17320
- sidebarTab: cycleValue(SIDEBAR_TABS, state.sidebarTab, 1),
17783
+ sidebarTab: next,
17784
+ userSidebarTab: next,
17321
17785
  pendingKey: undefined,
17322
17786
  };
17787
+ }
17323
17788
  case 'page':
17324
17789
  return {
17325
17790
  ...state,
@@ -17353,12 +17818,15 @@ function applyLogInkAction(state, action) {
17353
17818
  diffPreviewOffset: nextHunkOffset(state.diffPreviewOffset, action.hunkOffsets, action.delta),
17354
17819
  pendingKey: undefined,
17355
17820
  };
17356
- case 'previousSidebarTab':
17821
+ case 'previousSidebarTab': {
17822
+ const previous = cycleValue(SIDEBAR_TABS, state.sidebarTab, -1);
17357
17823
  return {
17358
17824
  ...state,
17359
- sidebarTab: cycleValue(SIDEBAR_TABS, state.sidebarTab, -1),
17825
+ sidebarTab: previous,
17826
+ userSidebarTab: previous,
17360
17827
  pendingKey: undefined,
17361
17828
  };
17829
+ }
17362
17830
  case 'setFilter':
17363
17831
  return withFilter$1(state, action.value, action.promotedSelections);
17364
17832
  case 'setActiveView':
@@ -17406,6 +17874,21 @@ function applyLogInkAction(state, action) {
17406
17874
  diffSource: 'worktree',
17407
17875
  };
17408
17876
  }
17877
+ case 'navigateOpenDiffForStash': {
17878
+ const next = withPushedView(state, 'diff');
17879
+ return {
17880
+ ...next,
17881
+ diffSource: 'stash',
17882
+ stashDiffRef: action.ref,
17883
+ selectedStashIndex: Math.max(0, action.stashIndex ?? state.selectedStashIndex),
17884
+ // Reset the diff scroll offset so the stash patch always opens
17885
+ // at the top, mirroring `navigateOpenDiffForCommit`. Without
17886
+ // this, opening a stash inherits whatever offset the previous
17887
+ // diff had, landing the user mid-patch.
17888
+ diffPreviewOffset: 0,
17889
+ worktreeDiffOffset: 0,
17890
+ };
17891
+ }
17409
17892
  case 'navigateOpenComposeForFile': {
17410
17893
  const next = withPushedView(state, 'status');
17411
17894
  return {
@@ -17430,9 +17913,22 @@ function applyLogInkAction(state, action) {
17430
17913
  return {
17431
17914
  ...state,
17432
17915
  sidebarTab: action.value,
17916
+ userSidebarTab: action.value,
17433
17917
  focus: 'sidebar',
17434
17918
  pendingKey: undefined,
17435
17919
  };
17920
+ case 'restoreSidebarTab':
17921
+ // Mount-time restore from per-repo persistence (#21). Updates the
17922
+ // tab + the user-choice mirror without forcing focus into the
17923
+ // sidebar — that's the focus-steal regression flagged in the PR
17924
+ // review. Users land on commits as usual; their saved tab is
17925
+ // visible in the sidebar but doesn't grab the cursor.
17926
+ return {
17927
+ ...state,
17928
+ sidebarTab: action.value,
17929
+ userSidebarTab: action.value,
17930
+ pendingKey: undefined,
17931
+ };
17436
17932
  case 'setStatus':
17437
17933
  return {
17438
17934
  ...state,
@@ -17444,6 +17940,7 @@ function applyLogInkAction(state, action) {
17444
17940
  ...state,
17445
17941
  workflowActionId: action.value,
17446
17942
  pendingConfirmationId: undefined,
17943
+ pendingConfirmationPayload: undefined,
17447
17944
  pendingMutationConfirmation: undefined,
17448
17945
  pendingKey: undefined,
17449
17946
  };
@@ -17451,6 +17948,7 @@ function applyLogInkAction(state, action) {
17451
17948
  return {
17452
17949
  ...state,
17453
17950
  pendingConfirmationId: action.value,
17951
+ pendingConfirmationPayload: action.value ? action.payload : undefined,
17454
17952
  workflowActionId: action.value ? undefined : state.workflowActionId,
17455
17953
  pendingMutationConfirmation: action.value ? undefined : state.pendingMutationConfirmation,
17456
17954
  pendingKey: undefined,
@@ -17460,6 +17958,7 @@ function applyLogInkAction(state, action) {
17460
17958
  ...state,
17461
17959
  pendingMutationConfirmation: action.value,
17462
17960
  pendingConfirmationId: action.value ? undefined : state.pendingConfirmationId,
17961
+ pendingConfirmationPayload: action.value ? undefined : state.pendingConfirmationPayload,
17463
17962
  workflowActionId: action.value ? undefined : state.workflowActionId,
17464
17963
  pendingKey: undefined,
17465
17964
  };
@@ -18281,6 +18780,36 @@ function cherryPickCommit(git, commit) {
18281
18780
  }
18282
18781
  return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['cherry-pick', commit.hash]), `Cherry-picked ${commit.shortHash}`)));
18283
18782
  }
18783
+ /**
18784
+ * Materialize a single file's contents from a historical commit into the
18785
+ * working tree, leaving every other path untouched. Equivalent to
18786
+ * `git checkout <sha> -- <path>` for additions/modifications. When the
18787
+ * path no longer exists at <sha> (i.e. the commit deleted that file),
18788
+ * mirror the deletion in the worktree via `git rm --force`.
18789
+ *
18790
+ * Important: this overwrites the file in the working tree. The caller
18791
+ * is responsible for confirming with the user when the working tree
18792
+ * already has uncommitted changes to that path.
18793
+ */
18794
+ async function checkoutFileFromCommit(git, sha, path) {
18795
+ return checkoutOrDeleteFromRef(git, sha, path, sha.slice(0, 7));
18796
+ }
18797
+ async function checkoutOrDeleteFromRef(git, ref, path, label) {
18798
+ const exists = await pathExistsAtRef(git, ref, path);
18799
+ if (exists) {
18800
+ return runAction$5(() => git.raw(['checkout', ref, '--', path]), `Checked out ${path} from ${label}`);
18801
+ }
18802
+ return runAction$5(() => git.raw(['rm', '--force', '--quiet', '--', path]), `Removed ${path} (mirrors deletion from ${label})`);
18803
+ }
18804
+ async function pathExistsAtRef(git, ref, path) {
18805
+ try {
18806
+ await git.raw(['cat-file', '-e', `${ref}:${path}`]);
18807
+ return true;
18808
+ }
18809
+ catch {
18810
+ return false;
18811
+ }
18812
+ }
18284
18813
  function revertCommit(git, commit) {
18285
18814
  if (!commit) {
18286
18815
  return Promise.resolve({
@@ -18430,6 +18959,20 @@ function popStash(git, stash) {
18430
18959
  function dropStash(git, stash) {
18431
18960
  return runAction$4(() => git.raw(['stash', 'drop', stash.ref]), `Dropped ${stash.ref}`);
18432
18961
  }
18962
+ /**
18963
+ * Materialize a single file's contents from a stash into the working
18964
+ * tree, leaving the rest of the stash untouched. Equivalent to
18965
+ * `git checkout <stashRef> -- <path>` for additions/modifications. When
18966
+ * the path doesn't exist at <stashRef> — i.e. the stash recorded a
18967
+ * deletion — mirror that deletion in the worktree.
18968
+ *
18969
+ * Important: this overwrites the file in the working tree. The caller
18970
+ * is responsible for confirming with the user when the working tree
18971
+ * already has uncommitted changes to that path.
18972
+ */
18973
+ function checkoutFileFromStash(git, stashRef, path) {
18974
+ return checkoutOrDeleteFromRef(git, stashRef, path, stashRef);
18975
+ }
18433
18976
 
18434
18977
  function parseStashSubject(subject) {
18435
18978
  const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
@@ -18482,6 +19025,68 @@ async function getStashDiffSummary(git, stashRef) {
18482
19025
  .map((line) => line.trimEnd())
18483
19026
  .filter(Boolean);
18484
19027
  }
19028
+ /**
19029
+ * Full unified-patch diff for a stash. Used by the diff surface when
19030
+ * `state.diffSource === 'stash'` to render the stash's changes inline.
19031
+ *
19032
+ * Empty stashes (e.g. created by `git stash --keep-index` against an
19033
+ * already-clean tree) return [] rather than throwing — surfaces fall
19034
+ * back to a "no diff to display" message.
19035
+ */
19036
+ async function getStashDiff(git, stashRef) {
19037
+ return (await git.raw(['stash', 'show', '-p', stashRef]))
19038
+ .split('\n')
19039
+ .map((line) => line.replace(/\r$/, ''));
19040
+ }
19041
+ /**
19042
+ * Slice a unified-patch into per-file sections. Each entry records the
19043
+ * file path and the offset of its `diff --git` header within `lines`.
19044
+ * Used by the stash explorer to build a per-file cursor + cherry-pick
19045
+ * the file at the cursor.
19046
+ *
19047
+ * Renames / moves return the destination path (the `b/` side); the
19048
+ * action surface treats that as the path to materialize from the stash.
19049
+ *
19050
+ * Path quoting: git wraps paths containing spaces or special characters
19051
+ * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
19052
+ * The parser handles both the unquoted and quoted forms; without that,
19053
+ * stash-file navigation and cherry-pick silently broke for any file
19054
+ * whose path contained a space.
19055
+ */
19056
+ function parseStashDiffFiles(lines) {
19057
+ const files = [];
19058
+ for (let i = 0; i < lines.length; i += 1) {
19059
+ const line = lines[i];
19060
+ const parsed = parseDiffGitHeader(line);
19061
+ if (parsed) {
19062
+ files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
19063
+ }
19064
+ }
19065
+ return files;
19066
+ }
19067
+ const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
19068
+ function parseDiffGitHeader(line) {
19069
+ const match = line.match(DIFF_GIT_HEADER);
19070
+ if (!match)
19071
+ return undefined;
19072
+ const aPath = unescapeGitQuoted(match[1]) || match[2];
19073
+ const bPath = unescapeGitQuoted(match[3]) || match[4];
19074
+ if (!aPath || !bPath)
19075
+ return undefined;
19076
+ return { aPath, bPath };
19077
+ }
19078
+ function unescapeGitQuoted(value) {
19079
+ if (value === undefined)
19080
+ return undefined;
19081
+ // Git's diff header quoting escapes `"`, `\`, and the usual
19082
+ // C-style sequences. Reverse the most common ones so callers get the
19083
+ // raw on-disk path.
19084
+ return value
19085
+ .replace(/\\\\/g, '\\')
19086
+ .replace(/\\"/g, '"')
19087
+ .replace(/\\t/g, '\t')
19088
+ .replace(/\\n/g, '\n');
19089
+ }
18485
19090
 
18486
19091
  async function runAction$3(action, successMessage) {
18487
19092
  try {
@@ -21086,10 +21691,6 @@ function LogInkApp(deps) {
21086
21691
  const h = React.createElement;
21087
21692
  const { exit } = useApp();
21088
21693
  const windowSize = useWindowSize();
21089
- const layout = getLogInkLayout({
21090
- columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
21091
- rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
21092
- });
21093
21694
  // Bumping this on SIGCONT forces the existing tree to repaint so users
21094
21695
  // land on a drawn screen after `fg` instead of an empty alt buffer.
21095
21696
  const [, setResumeTick] = React.useState(0);
@@ -21117,6 +21718,13 @@ function LogInkApp(deps) {
21117
21718
  const [worktreeDiffLoading, setWorktreeDiffLoading] = React.useState(false);
21118
21719
  const [worktreeHunks, setWorktreeHunks] = React.useState(undefined);
21119
21720
  const [worktreeHunksLoading, setWorktreeHunksLoading] = React.useState(false);
21721
+ // Stash diff explorer (Enter on a stash row): the runtime fetches
21722
+ // `git stash show -p <ref>` lazily once the diff view becomes active
21723
+ // with diffSource='stash'. Lines are stored as a flat string[] —
21724
+ // renderDiffSurface paints each line through diffLineProps so +/-
21725
+ // colors match the commit-diff path.
21726
+ const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
21727
+ const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
21120
21728
  const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
21121
21729
  getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
21122
21730
  const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
@@ -21161,6 +21769,36 @@ function LogInkApp(deps) {
21161
21769
  const dispatch = React.useCallback((action) => {
21162
21770
  setState((current) => applyLogInkAction(current, action));
21163
21771
  }, []);
21772
+ // Auto-dismiss status messages after a short window so transient
21773
+ // confirmations ("Pulled current branch", "Edited foo.ts") don't
21774
+ // linger forever. Each new message resets the timer; clearing the
21775
+ // message via setStatus(undefined) cancels it. Doesn't fire while a
21776
+ // modal (input prompt, confirmation, palette) is open — those flows
21777
+ // use the status line as live feedback for the open task.
21778
+ React.useEffect(() => {
21779
+ if (!state.statusMessage)
21780
+ return;
21781
+ if (state.inputPrompt || state.pendingConfirmationId || state.pendingMutationConfirmation || state.showCommandPalette) {
21782
+ return;
21783
+ }
21784
+ // The `setTimeout` callback is a literal arrow function (not a
21785
+ // string), and the delay is a hard-coded constant, so the
21786
+ // eval-injection vector behind DevSkim DS172411 doesn't apply here.
21787
+ // DevSkim: ignore DS172411
21788
+ const handle = setTimeout(() => {
21789
+ if (mountedRef.current) {
21790
+ dispatch({ type: 'setStatus', value: undefined });
21791
+ }
21792
+ }, 4000);
21793
+ return () => clearTimeout(handle);
21794
+ }, [
21795
+ dispatch,
21796
+ state.inputPrompt,
21797
+ state.pendingConfirmationId,
21798
+ state.pendingMutationConfirmation,
21799
+ state.showCommandPalette,
21800
+ state.statusMessage,
21801
+ ]);
21164
21802
  const refreshContext = React.useCallback(async (options = {}) => {
21165
21803
  // Loud refresh (manual `r`): flip everything to 'loading' so the user
21166
21804
  // sees the surfaces clear, then settle to 'ready' on completion.
@@ -21240,6 +21878,61 @@ function LogInkApp(deps) {
21240
21878
  watcher?.close();
21241
21879
  };
21242
21880
  }, [git, refreshContext, refreshWorktreeContext]);
21881
+ // Per-repo sidebar tab persistence (#21). Resolve the repo root, look
21882
+ // up the cached tab, and dispatch `restoreSidebarTab` once on mount so
21883
+ // the user lands on whichever tab they were last on for this project.
21884
+ // `restoreSidebarTab` (vs `setSidebarTab`) intentionally does not pull
21885
+ // focus into the sidebar — the user lands on commits, the saved tab
21886
+ // is just visible in the gutter.
21887
+ //
21888
+ // The save effect listens to `userSidebarTab` (the user's explicit
21889
+ // choice mirror), not `sidebarTab`. That way the auto-switch to
21890
+ // Branches when entering compose / status doesn't overwrite the saved
21891
+ // preference.
21892
+ const repoRootRef = React.useRef(undefined);
21893
+ React.useEffect(() => {
21894
+ let cancelled = false;
21895
+ void (async () => {
21896
+ try {
21897
+ const repoRoot = (await git.revparse(['--show-toplevel'])).trim();
21898
+ if (cancelled || !repoRoot)
21899
+ return;
21900
+ repoRootRef.current = repoRoot;
21901
+ const saved = getSavedSidebarTab(repoRoot);
21902
+ if (saved && saved !== state.userSidebarTab) {
21903
+ dispatch({ type: 'restoreSidebarTab', value: saved });
21904
+ }
21905
+ }
21906
+ catch {
21907
+ // Not in a worktree, or revparse failed; nothing to restore.
21908
+ }
21909
+ })();
21910
+ return () => { cancelled = true; };
21911
+ }, [git, dispatch]);
21912
+ React.useEffect(() => {
21913
+ const repoRoot = repoRootRef.current;
21914
+ if (!repoRoot)
21915
+ return;
21916
+ saveSidebarTab(repoRoot, state.userSidebarTab);
21917
+ }, [state.userSidebarTab]);
21918
+ // P-stash-explorer: load `git stash show -p <ref>` once the diff view
21919
+ // becomes active with diffSource='stash'. Best-effort — empty stashes
21920
+ // or read errors fall through to a "no diff" hint at the render site.
21921
+ React.useEffect(() => {
21922
+ if (state.activeView !== 'diff' || state.diffSource !== 'stash' || !state.stashDiffRef) {
21923
+ return;
21924
+ }
21925
+ let active = true;
21926
+ setStashDiffLoading(true);
21927
+ void (async () => {
21928
+ const lines = await safe(getStashDiff(git, state.stashDiffRef));
21929
+ if (active) {
21930
+ setStashDiffLines(lines || []);
21931
+ setStashDiffLoading(false);
21932
+ }
21933
+ })();
21934
+ return () => { active = false; };
21935
+ }, [git, state.activeView, state.diffSource, state.stashDiffRef]);
21243
21936
  React.useEffect(() => {
21244
21937
  let active = true;
21245
21938
  async function loadWorktreeHunks() {
@@ -21450,13 +22143,96 @@ function LogInkApp(deps) {
21450
22143
  });
21451
22144
  dispatch({ type: 'setStatus', value: result.message });
21452
22145
  }, [dispatch]);
22146
+ // Open a file in $EDITOR (or $VISUAL) by suspending Ink's hold on the
22147
+ // terminal, spawning the editor synchronously inheriting stdio, then
22148
+ // restoring the alt screen + raw mode and forcing a re-render. The
22149
+ // dance mirrors the SIGTSTP / SIGCONT path in inkTerminalLifecycle.
22150
+ // Falls back to vi when neither env var is set; surfaces a status
22151
+ // message on missing-binary / non-zero exit so the user isn't left
22152
+ // wondering.
22153
+ const openInEditor = React.useCallback((path) => {
22154
+ if (!path)
22155
+ return;
22156
+ const editorEnv = process.env.VISUAL || process.env.EDITOR || 'vi';
22157
+ // $VISUAL / $EDITOR commonly include flags (`code -w`, `vim -f`,
22158
+ // `emacs -nw`). Tokenize on whitespace so the leading word is the
22159
+ // executable and the rest are passed as arguments — passing the
22160
+ // full string to spawnSync as the executable would fail with
22161
+ // ENOENT for any of those configurations.
22162
+ const editorArgs = editorEnv.trim().split(/\s+/).filter(Boolean);
22163
+ const editor = editorArgs[0] || 'vi';
22164
+ const editorPrefixArgs = editorArgs.slice(1);
22165
+ const out = process.stdout;
22166
+ const stdin = process.stdin;
22167
+ const ENTER_ALT = '\x1b[?1049h';
22168
+ const EXIT_ALT = '\x1b[?1049l';
22169
+ const SHOW_CURSOR = '\x1b[?25h';
22170
+ const HIDE_CURSOR = '\x1b[?25l';
22171
+ try {
22172
+ // Drop into the primary buffer + cooked mode so the editor
22173
+ // doesn't inherit our raw-mode keystrokes.
22174
+ stdin.setRawMode?.(false);
22175
+ out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
22176
+ const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
22177
+ if (result.error) {
22178
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
22179
+ }
22180
+ else if (result.signal) {
22181
+ // Editor was killed by a signal (e.g. ^C, SIGTERM). status is
22182
+ // null in this case, so the old `status !== 0` check would
22183
+ // mistakenly fall through to the success branch.
22184
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
22185
+ }
22186
+ else if (typeof result.status === 'number' && result.status !== 0) {
22187
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
22188
+ }
22189
+ else {
22190
+ dispatch({ type: 'setStatus', value: `Edited ${path}` });
22191
+ }
22192
+ }
22193
+ finally {
22194
+ // Re-enter the alt screen + raw mode + hidden cursor; nudge React
22195
+ // so the freshly-restored screen actually paints.
22196
+ out.write(`${ENTER_ALT}${HIDE_CURSOR}`);
22197
+ stdin.setRawMode?.(true);
22198
+ resumeRef?.current?.();
22199
+ }
22200
+ // Worktree status may have changed (e.g. user saved an edit) — silent
22201
+ // refresh so the file row reflects the new staged/unstaged state.
22202
+ void refreshWorktreeContext({ silent: true });
22203
+ }, [dispatch, refreshWorktreeContext, resumeRef]);
21453
22204
  // Resolve the destructive-action target from the live filtered+sorted
21454
22205
  // list the user is looking at, run the action against it, surface the
21455
22206
  // result on the status line, and silently refresh so the deleted item
21456
22207
  // disappears. Called from the y-confirm path for delete-branch / delete-
21457
22208
  // tag / drop-stash / remove-worktree / abort-operation.
21458
- const runWorkflowAction = React.useCallback(async (id) => {
22209
+ const runWorkflowAction = React.useCallback(async (id, payload) => {
21459
22210
  const handlers = {
22211
+ 'create-branch': async () => {
22212
+ const name = payload?.trim();
22213
+ if (!name)
22214
+ return { ok: false, message: 'Branch name required' };
22215
+ const startPoint = context.branches?.currentBranch || 'HEAD';
22216
+ return createBranch(git, name, startPoint);
22217
+ },
22218
+ 'create-tag': async () => {
22219
+ const name = payload?.trim();
22220
+ if (!name)
22221
+ return { ok: false, message: 'Tag name required' };
22222
+ return createLightweightTag(git, name, 'HEAD');
22223
+ },
22224
+ 'checkout-branch': async () => {
22225
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
22226
+ const visible = state.filter
22227
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
22228
+ : all;
22229
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
22230
+ if (!branch)
22231
+ return { ok: false, message: 'No branch selected' };
22232
+ if (branch.current)
22233
+ return { ok: true, message: `Already on ${branch.shortName}` };
22234
+ return checkoutBranch(git, branch);
22235
+ },
21460
22236
  'delete-branch': async () => {
21461
22237
  const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
21462
22238
  const visible = state.filter
@@ -21477,6 +22253,16 @@ function LogInkApp(deps) {
21477
22253
  return { ok: false, message: 'No tag selected' };
21478
22254
  return deleteLocalTag(git, tag.name);
21479
22255
  },
22256
+ 'push-tag': async () => {
22257
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
22258
+ const visible = state.filter
22259
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
22260
+ : all;
22261
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
22262
+ if (!tag)
22263
+ return { ok: false, message: 'No tag selected' };
22264
+ return pushTag(git, tag.name);
22265
+ },
21480
22266
  'drop-stash': async () => {
21481
22267
  const all = context.stashes?.stashes || [];
21482
22268
  const visible = state.filter
@@ -21487,14 +22273,82 @@ function LogInkApp(deps) {
21487
22273
  return { ok: false, message: 'No stash selected' };
21488
22274
  return dropStash(git, stash);
21489
22275
  },
22276
+ 'apply-stash': async () => {
22277
+ const all = context.stashes?.stashes || [];
22278
+ const visible = state.filter
22279
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
22280
+ : all;
22281
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
22282
+ if (!stash)
22283
+ return { ok: false, message: 'No stash selected' };
22284
+ return applyStash(git, stash);
22285
+ },
22286
+ 'pop-stash': async () => {
22287
+ const all = context.stashes?.stashes || [];
22288
+ const visible = state.filter
22289
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
22290
+ : all;
22291
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
22292
+ if (!stash)
22293
+ return { ok: false, message: 'No stash selected' };
22294
+ return popStash(git, stash);
22295
+ },
22296
+ 'checkout-file-from-stash': async () => {
22297
+ const path = payload?.trim();
22298
+ const ref = state.stashDiffRef;
22299
+ if (!path)
22300
+ return { ok: false, message: 'No stash file under cursor' };
22301
+ if (!ref)
22302
+ return { ok: false, message: 'No stash ref active' };
22303
+ return checkoutFileFromStash(git, ref, path);
22304
+ },
22305
+ 'cherry-pick-commit': async () => {
22306
+ const commit = getSelectedInkCommit(state);
22307
+ if (!commit)
22308
+ return { ok: false, message: 'No commit selected' };
22309
+ return cherryPickCommit(git, {
22310
+ hash: commit.hash,
22311
+ shortHash: commit.shortHash,
22312
+ message: commit.message,
22313
+ });
22314
+ },
22315
+ 'checkout-file-from-commit': async () => {
22316
+ // payload is "<sha> <path>" so we pass both through a single
22317
+ // string field on the action.
22318
+ const trimmed = payload?.trim();
22319
+ if (!trimmed)
22320
+ return { ok: false, message: 'No commit file under cursor' };
22321
+ const spaceIndex = trimmed.indexOf(' ');
22322
+ if (spaceIndex < 0)
22323
+ return { ok: false, message: 'Malformed commit file payload' };
22324
+ const sha = trimmed.slice(0, spaceIndex);
22325
+ const path = trimmed.slice(spaceIndex + 1);
22326
+ if (!sha || !path)
22327
+ return { ok: false, message: 'No commit file under cursor' };
22328
+ return checkoutFileFromCommit(git, sha, path);
22329
+ },
21490
22330
  'remove-worktree': async () => {
21491
22331
  const all = context.worktreeList?.worktrees || [];
21492
- // No dedicated cursor for the worktrees tab yet operate on the
21493
- // first non-current worktree as a safe default.
21494
- const target = all.find((w) => !w.current);
21495
- if (!target)
21496
- return { ok: false, message: 'No removable worktree' };
21497
- return removeWorktree(git, target);
22332
+ // Resolve the target from the visible (filtered) list so a
22333
+ // hidden filtered-out worktree can never be the action target.
22334
+ // Falls back to the cursor against the unfiltered list when the
22335
+ // action is invoked from the palette without ever visiting the
22336
+ // worktrees view.
22337
+ const visible = state.filter
22338
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
22339
+ : all;
22340
+ const cursorTarget = visible.length
22341
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
22342
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
22343
+ if (!cursorTarget)
22344
+ return { ok: false, message: 'No worktree selected' };
22345
+ if (cursorTarget.current) {
22346
+ return {
22347
+ ok: false,
22348
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
22349
+ };
22350
+ }
22351
+ return removeWorktree(git, cursorTarget);
21498
22352
  },
21499
22353
  'abort-operation': async () => {
21500
22354
  const operation = context.operation?.operation;
@@ -21503,6 +22357,64 @@ function LogInkApp(deps) {
21503
22357
  }
21504
22358
  return abortOperation(git, operation);
21505
22359
  },
22360
+ 'open-pr': async () => {
22361
+ const repo = context.provider?.repository;
22362
+ if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
22363
+ return { ok: false, message: 'No GitHub remote detected for this repo' };
22364
+ }
22365
+ const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
22366
+ if (pr) {
22367
+ return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
22368
+ }
22369
+ // No PR — fall back to opening the repo page so the user can
22370
+ // create one or browse from there.
22371
+ return openProviderUrl(repo, { type: 'repo' });
22372
+ },
22373
+ 'fetch-remotes': async () => fetchRemotes(git),
22374
+ 'pull-current-branch': async () => pullCurrentBranch(git),
22375
+ 'push-current-branch': async () => pushCurrentBranch(git),
22376
+ 'rename-branch': async () => {
22377
+ const newName = payload?.trim();
22378
+ if (!newName)
22379
+ return { ok: false, message: 'New branch name required' };
22380
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
22381
+ const visible = state.filter
22382
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
22383
+ : all;
22384
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
22385
+ if (!branch)
22386
+ return { ok: false, message: 'No branch selected' };
22387
+ return renameBranch(git, branch.shortName, newName);
22388
+ },
22389
+ 'set-upstream': async () => {
22390
+ const upstream = payload?.trim();
22391
+ if (!upstream)
22392
+ return { ok: false, message: 'Upstream ref required' };
22393
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
22394
+ const visible = state.filter
22395
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
22396
+ : all;
22397
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
22398
+ if (!branch)
22399
+ return { ok: false, message: 'No branch selected' };
22400
+ return setUpstream(git, branch.shortName, upstream);
22401
+ },
22402
+ 'delete-remote-tag': async () => {
22403
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
22404
+ const visible = state.filter
22405
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
22406
+ : all;
22407
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
22408
+ if (!tag)
22409
+ return { ok: false, message: 'No tag selected' };
22410
+ return deleteRemoteTag(git, tag.name);
22411
+ },
22412
+ 'create-stash': async () => {
22413
+ const message = payload?.trim();
22414
+ if (!message)
22415
+ return { ok: false, message: 'Stash message required' };
22416
+ return createStash(git, message);
22417
+ },
21506
22418
  };
21507
22419
  const handler = handlers[id];
21508
22420
  if (!handler) {
@@ -21515,7 +22427,8 @@ function LogInkApp(deps) {
21515
22427
  // flickering the surfaces through a 'loading' phase.
21516
22428
  await refreshContext({ silent: true });
21517
22429
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
21518
- state.selectedStashIndex, state.selectedTagIndex, state.tagSort]);
22430
+ state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
22431
+ state.tagSort]);
21519
22432
  React.useEffect(() => {
21520
22433
  let active = true;
21521
22434
  async function loadPreview() {
@@ -21621,14 +22534,53 @@ function LogInkApp(deps) {
21621
22534
  .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
21622
22535
  .length
21623
22536
  : context.tags?.tags.length;
21624
- const stashVisibleCount = state.filter
22537
+ const visibleStashes = state.filter
21625
22538
  ? (context.stashes?.stashes || [])
21626
22539
  .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
22540
+ : (context.stashes?.stashes || []);
22541
+ const stashVisibleCount = visibleStashes.length;
22542
+ const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
22543
+ // The worktrees promoted view is filterable; mirror the branches /
22544
+ // tags / stash pattern and feed the filtered count into the input
22545
+ // dispatcher so ↑/↓ stay synchronized with the visible rows.
22546
+ const worktreeVisibleCount = state.filter
22547
+ ? (context.worktreeList?.worktrees || [])
22548
+ .filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
21627
22549
  .length
21628
- : context.stashes?.stashes.length;
22550
+ : context.worktreeList?.worktrees.length;
22551
+ // When the diff view is showing a stash patch, swap the previewLineCount
22552
+ // to the stash diff length so the existing pageDetailPreview path
22553
+ // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
22554
+ const diffPreviewLineCount = state.diffSource === 'stash'
22555
+ ? stashDiffLines?.length
22556
+ : filePreview?.hunks.length;
22557
+ // Parse the active stash diff into per-file sections so `]`/`[` can
22558
+ // jump between files and `c` knows which path the cursor is on for
22559
+ // a file-level cherry-pick.
22560
+ const stashDiffFiles = state.diffSource === 'stash' && stashDiffLines
22561
+ ? parseStashDiffFiles(stashDiffLines)
22562
+ : [];
22563
+ const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
22564
+ const stashDiffSelectedPath = (() => {
22565
+ if (state.diffSource !== 'stash' || stashDiffFiles.length === 0)
22566
+ return undefined;
22567
+ const offset = state.diffPreviewOffset;
22568
+ // Walk backwards to the most recent file header at or before the
22569
+ // current cursor offset.
22570
+ let current = stashDiffFiles[0];
22571
+ for (const file of stashDiffFiles) {
22572
+ if (file.startLine <= offset) {
22573
+ current = file;
22574
+ }
22575
+ else {
22576
+ break;
22577
+ }
22578
+ }
22579
+ return current.path;
22580
+ })();
21629
22581
  getLogInkInputEvents(state, inputValue, key, {
21630
22582
  detailFileCount: detail?.files.length,
21631
- previewLineCount: filePreview?.hunks.length,
22583
+ previewLineCount: diffPreviewLineCount,
21632
22584
  worktreeDiffLineCount: worktreeDiff?.lines.length,
21633
22585
  worktreeFileCount: context.worktree?.files.length,
21634
22586
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
@@ -21636,6 +22588,17 @@ function LogInkApp(deps) {
21636
22588
  branchCount: branchVisibleCount,
21637
22589
  tagCount: tagVisibleCount,
21638
22590
  stashCount: stashVisibleCount,
22591
+ stashSelectedRef,
22592
+ stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
22593
+ stashDiffSelectedPath,
22594
+ worktreeListCount: worktreeVisibleCount,
22595
+ worktreeSelectedPath: context.worktree?.files[state.selectedWorktreeFileIndex]?.path,
22596
+ commitDiffSelectedPath: state.diffSource === 'commit'
22597
+ ? selectedDetailFile?.path
22598
+ : undefined,
22599
+ commitDiffSelectedSha: state.diffSource === 'commit'
22600
+ ? selected?.hash
22601
+ : undefined,
21639
22602
  worktreeDirty,
21640
22603
  }).forEach((event) => {
21641
22604
  if (event.type === 'exit') {
@@ -21663,7 +22626,10 @@ function LogInkApp(deps) {
21663
22626
  void runAiCommitDraft();
21664
22627
  }
21665
22628
  else if (event.type === 'runWorkflowAction') {
21666
- void runWorkflowAction(event.id);
22629
+ void runWorkflowAction(event.id, event.payload);
22630
+ }
22631
+ else if (event.type === 'openFileInEditor') {
22632
+ openInEditor(event.path);
21667
22633
  }
21668
22634
  else {
21669
22635
  // P4.5: enrich filter-mutating actions with a precomputed
@@ -21677,6 +22643,13 @@ function LogInkApp(deps) {
21677
22643
  }
21678
22644
  });
21679
22645
  });
22646
+ // Layout depends on focus (sidebar grows when focused), so it's
22647
+ // computed here — after state is in scope but before the render path.
22648
+ const layout = getLogInkLayout({
22649
+ columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
22650
+ rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
22651
+ sidebarFocused: state.focus === 'sidebar',
22652
+ });
21680
22653
  if (layout.tooSmall) {
21681
22654
  return h(Box, {
21682
22655
  flexDirection: 'column',
@@ -21690,7 +22663,7 @@ function LogInkApp(deps) {
21690
22663
  if (showOnboarding) {
21691
22664
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
21692
22665
  }
21693
- 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));
22666
+ 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));
21694
22667
  }
21695
22668
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
21696
22669
  const { Box, Text } = components;
@@ -21746,28 +22719,96 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
21746
22719
  function renderSidebar(h, components, state, context, contextStatus, width, theme) {
21747
22720
  const { Box, Text } = components;
21748
22721
  const focused = state.focus === 'sidebar';
21749
- const lines = sidebarLines(context, contextStatus, state.sidebarTab, width - 4, state, theme);
21750
22722
  const tabs = getLogInkSidebarTabs();
22723
+ // Accordion layout — every tab's title is visible on its own line, but
22724
+ // only the active tab expands its content underneath. Switching tabs
22725
+ // (1-5 / [/]) collapses the previous and expands the next.
22726
+ const tabBlocks = tabs.flatMap((tab, tabIndex) => {
22727
+ const isActive = tab === state.sidebarTab;
22728
+ const count = sidebarTabCount(tab, context);
22729
+ const labelWithCount = count !== undefined
22730
+ ? `${sidebarTabLabel(tab)} (${count})`
22731
+ : sidebarTabLabel(tab);
22732
+ const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
22733
+ const blocks = [];
22734
+ if (tabIndex > 0) {
22735
+ blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
22736
+ }
22737
+ blocks.push(h(Text, {
22738
+ key: `tab-header-${tab}`,
22739
+ bold: isActive,
22740
+ dimColor: !isActive,
22741
+ }, headerText));
22742
+ if (isActive) {
22743
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
22744
+ }
22745
+ return blocks;
22746
+ });
21751
22747
  return h(Box, {
21752
22748
  borderColor: focusBorderColor(theme, focused),
21753
22749
  borderStyle: theme.borderStyle,
21754
22750
  flexDirection: 'column',
21755
22751
  width,
21756
22752
  paddingX: 1,
21757
- }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, { dimColor: true }, tabs.map((tab) => {
21758
- const count = sidebarTabCount(tab, context);
21759
- const labelWithCount = count !== undefined
21760
- ? `${sidebarTabLabel(tab)} (${count})`
21761
- : sidebarTabLabel(tab);
21762
- return tab === state.sidebarTab ? `[${labelWithCount}]` : labelWithCount;
21763
- }).join(' ')), h(Text, undefined, ''), ...lines.map((line, index) => h(Text, { key: `sidebar-${index}` }, truncate$1(line, width - 4))));
22753
+ }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, undefined, ''), ...tabBlocks);
22754
+ }
22755
+ /**
22756
+ * Render the indented body of the active sidebar tab. The status tab
22757
+ * colours its summary counts (warning / danger / muted) and per-file
22758
+ * rows so they read as the same severity scale used in the main status
22759
+ * surface; every other tab falls through to `sidebarLines` for its
22760
+ * string-based summary.
22761
+ */
22762
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
22763
+ if (tab === 'status') {
22764
+ return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
22765
+ }
22766
+ const lines = sidebarLines(context, contextStatus, tab, width - 6, state, theme);
22767
+ return lines.map((line, index) => h(Text, {
22768
+ key: `tab-content-${tab}-${index}`,
22769
+ dimColor: !line.trim(),
22770
+ }, truncate$1(` ${line}`, width - 4)));
22771
+ }
22772
+ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
22773
+ if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
22774
+ return [h(Text, { key: 'tab-status-loading', dimColor: true }, ' Loading status…')];
22775
+ }
22776
+ const worktree = context.worktree;
22777
+ if (!worktree) {
22778
+ return [h(Text, { key: 'tab-status-empty', dimColor: true }, ' Status unavailable')];
22779
+ }
22780
+ const colorOf = (state) => {
22781
+ if (theme.noColor)
22782
+ return undefined;
22783
+ if (state === 'staged')
22784
+ return theme.colors.warning;
22785
+ if (state === 'unstaged')
22786
+ return theme.colors.danger;
22787
+ return theme.colors.muted;
22788
+ };
22789
+ const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
22790
+ const fileRows = worktree.files.slice(0, 12).map((file, index) => {
22791
+ const codes = `${file.indexStatus}${file.worktreeStatus}`;
22792
+ return h(Text, {
22793
+ key: `tab-status-file-${index}`,
22794
+ color: colorOf(file.state),
22795
+ }, truncate$1(` ${codes} ${file.path}`, width - 4));
22796
+ });
22797
+ return [
22798
+ summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
22799
+ summaryRow(worktree.unstagedCount, 'unstaged', 'tab-status-unstaged', 'unstaged'),
22800
+ summaryRow(worktree.untrackedCount, 'untracked', 'tab-status-untracked', 'untracked'),
22801
+ ...(fileRows.length
22802
+ ? [h(Text, { key: 'tab-status-spacer' }, ''), ...fileRows]
22803
+ : []),
22804
+ ];
21764
22805
  }
21765
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
22806
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
21766
22807
  if (state.activeView === 'status') {
21767
22808
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
21768
22809
  }
21769
22810
  if (state.activeView === 'diff') {
21770
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme);
22811
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
21771
22812
  }
21772
22813
  if (state.activeView === 'compose') {
21773
22814
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -21781,6 +22822,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
21781
22822
  if (state.activeView === 'stash') {
21782
22823
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
21783
22824
  }
22825
+ if (state.activeView === 'worktrees') {
22826
+ return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
22827
+ }
21784
22828
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
21785
22829
  }
21786
22830
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -21990,15 +23034,25 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
21990
23034
  key: `compose-body-${index}`,
21991
23035
  dimColor: line === '<empty>',
21992
23036
  }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
21993
- }), h(Text, undefined, ''), ...(compose.loading
21994
- ? [h(Text, {
23037
+ }),
23038
+ // Loading indicator + post-action message belong inline with the draft
23039
+ // (they describe what just happened to the fields above). The state-
23040
+ // line ("Editing — Enter switches summary↔body…" / "Press e to edit
23041
+ // …") is footer-style guidance and now sits at the very bottom of the
23042
+ // pane so it doesn't visually separate the body from any
23043
+ // result/details.
23044
+ ...(compose.loading
23045
+ ? [
23046
+ h(Text, undefined, ''),
23047
+ h(Text, {
21995
23048
  key: 'compose-loading',
21996
23049
  bold: true,
21997
23050
  color: theme.noColor ? undefined : theme.colors.accent,
21998
23051
  }, theme.ascii
21999
23052
  ? '[...] Generating AI commit draft (this can take a moment)'
22000
- : '⏳ Generating AI commit draft… (this can take a moment)')]
22001
- : [h(Text, { dimColor: true }, stateLine)]), ...(compose.message ? [h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
23053
+ : '⏳ Generating AI commit draft… (this can take a moment)'),
23054
+ ]
23055
+ : []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
22002
23056
  key: `compose-detail-${index}`,
22003
23057
  dimColor: true,
22004
23058
  }, truncate$1(` ${line}`, 140))), ...(!hasStagedFiles && noStagedHint
@@ -22006,7 +23060,7 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
22006
23060
  h(Text, { key: 'compose-no-staged-spacer' }, ''),
22007
23061
  h(Text, { key: 'compose-no-staged', dimColor: true }, truncate$1(noStagedHint, 140)),
22008
23062
  ]
22009
- : []));
23063
+ : []), h(Box, { flexGrow: 1 }), h(Text, { key: 'compose-stateline', dimColor: true }, truncate$1(stateLine, width - 4)));
22010
23064
  }
22011
23065
  function matchesPromotedFilter(haystacks, filter) {
22012
23066
  if (!filter.trim()) {
@@ -22160,6 +23214,48 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
22160
23214
  width,
22161
23215
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
22162
23216
  }
23217
+ function renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
23218
+ const { Box, Text } = components;
23219
+ const focused = state.focus === 'commits';
23220
+ const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
23221
+ const allWorktrees = context.worktreeList?.worktrees || [];
23222
+ const worktrees = state.filter
23223
+ ? allWorktrees.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || '', entry.head || ''], state.filter))
23224
+ : allWorktrees;
23225
+ const selected = Math.max(0, Math.min(state.selectedWorktreeListIndex, Math.max(0, worktrees.length - 1)));
23226
+ const listRows = Math.max(4, bodyRows - 4);
23227
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
23228
+ const visible = worktrees.slice(startIndex, startIndex + listRows);
23229
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
23230
+ const headerRight = loading
23231
+ ? 'loading worktrees'
23232
+ : `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
23233
+ const lines = loading
23234
+ ? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
23235
+ : worktrees.length === 0
23236
+ ? [h(Text, { key: 'worktrees-empty', dimColor: true }, 'No linked worktrees.')]
23237
+ : visible.map((entry, offset) => {
23238
+ const index = startIndex + offset;
23239
+ const isSelected = index === selected;
23240
+ const cursor = isSelected ? '>' : ' ';
23241
+ const marker = entry.current ? '*' : ' ';
23242
+ const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
23243
+ const stateLabel = entry.dirty ? 'dirty' : 'clean';
23244
+ return h(Text, {
23245
+ key: `worktree-${index}`,
23246
+ bold: isSelected,
23247
+ dimColor: !isSelected && !entry.current,
23248
+ }, truncate$1(`${cursor} ${marker} ${branchLabel.padEnd(28)} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
23249
+ });
23250
+ return h(Box, {
23251
+ borderColor: focusBorderColor(theme, focused),
23252
+ borderStyle: theme.borderStyle,
23253
+ flexDirection: 'column',
23254
+ flexShrink: 0,
23255
+ paddingX: 1,
23256
+ width,
23257
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23258
+ }
22163
23259
  /**
22164
23260
  * Filter input cursor for the promoted views (branches/tags/stash).
22165
23261
  * History already shows the same `filter: foo_` affordance in its header
@@ -22178,12 +23274,67 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
22178
23274
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
22179
23275
  ];
22180
23276
  }
22181
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme) {
23277
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
22182
23278
  const { Box, Text } = components;
22183
23279
  const focused = state.focus === 'commits';
22184
23280
  const worktree = context.worktree;
22185
23281
  const worktreeFile = worktree?.files[state.selectedWorktreeFileIndex];
22186
23282
  const visibleRows = Math.max(4, bodyRows - 4);
23283
+ // Stash diff branch: when the user opened the diff via Enter on a stash
23284
+ // row, render the stash patch text directly. The patch is parsed into
23285
+ // per-file sections so `]` / `[` jumps between files and `c`
23286
+ // cherry-picks the file at the cursor.
23287
+ if (state.diffSource === 'stash') {
23288
+ const lines = stashDiffLines || [];
23289
+ const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23290
+ const stashFiles = parseStashDiffFiles(lines);
23291
+ const fileCount = stashFiles.length;
23292
+ const currentFile = (() => {
23293
+ if (fileCount === 0)
23294
+ return undefined;
23295
+ let current = stashFiles[0];
23296
+ for (const file of stashFiles) {
23297
+ if (file.startLine <= state.diffPreviewOffset) {
23298
+ current = file;
23299
+ }
23300
+ else {
23301
+ break;
23302
+ }
23303
+ }
23304
+ return current;
23305
+ })();
23306
+ const currentFileIndex = currentFile
23307
+ ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
23308
+ : -1;
23309
+ const headerLines = stashDiffLoading
23310
+ ? [`Loading diff for ${state.stashDiffRef || 'stash'}...`]
23311
+ : lines.length
23312
+ ? [
23313
+ `Stash: ${state.stashDiffRef || ''}`,
23314
+ fileCount > 0 && currentFile
23315
+ ? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
23316
+ : 'No files in this stash.',
23317
+ `Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
23318
+ '',
23319
+ ]
23320
+ : ['No diff to display for this stash.'];
23321
+ return h(Box, {
23322
+ borderColor: focusBorderColor(theme, focused),
23323
+ borderStyle: theme.borderStyle,
23324
+ flexDirection: 'column',
23325
+ flexShrink: 0,
23326
+ paddingX: 1,
23327
+ width,
23328
+ }, 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, {
23329
+ key: `stash-diff-header-${index}`,
23330
+ dimColor: index > 0,
23331
+ }, truncate$1(line, width - 4))), ...(stashDiffLoading || !lines.length
23332
+ ? []
23333
+ : visibleLines.map((line, index) => h(Text, {
23334
+ key: `stash-diff-line-${state.diffPreviewOffset + index}`,
23335
+ ...diffLineProps(line, theme),
23336
+ }, truncate$1(line, width - 4)))));
23337
+ }
22187
23338
  // diffSource disambiguates: 'commit' was set when the user opened the
22188
23339
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
22189
23340
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -22277,6 +23428,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
22277
23428
  if (state.showCommandPalette) {
22278
23429
  return renderCommandPalette(h, components, state, width, theme, focused);
22279
23430
  }
23431
+ if (state.inputPrompt) {
23432
+ return renderInputPromptPanel(h, components, state, width, theme, focused);
23433
+ }
22280
23434
  if (state.pendingConfirmationId || state.pendingMutationConfirmation) {
22281
23435
  return renderConfirmationPanel(h, components, state, width, theme, focused);
22282
23436
  }
@@ -22676,16 +23830,40 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
22676
23830
  }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
22677
23831
  key: `commit-header-${index}`,
22678
23832
  dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
22679
- }, truncate$1(line, width - 4))), loading
22680
- ? h(Text, {
22681
- key: 'commit-loading',
22682
- bold: true,
22683
- color: theme.noColor ? undefined : theme.colors.accent,
22684
- }, truncate$1(theme.ascii ? '[...] Generating AI draft' : '⏳ Generating AI draft…', width - 4))
22685
- : h(Text, { key: 'commit-state', dimColor: true }, truncate$1(stateLine, width - 4)), ...trailerLines.map((line, index) => h(Text, {
23833
+ }, truncate$1(line, width - 4))),
23834
+ // Loading indicator + commit result/details stay inline with the body
23835
+ // (they describe what just happened to the fields above). The action
23836
+ // hint ("e edit | c commit | I AI draft") moves to the bottom of the
23837
+ // pane to read as footer guidance, matching the compose surface.
23838
+ ...(loading
23839
+ ? [h(Text, {
23840
+ key: 'commit-loading',
23841
+ bold: true,
23842
+ color: theme.noColor ? undefined : theme.colors.accent,
23843
+ }, truncate$1(theme.ascii ? '[...] Generating AI draft' : '⏳ Generating AI draft…', width - 4))]
23844
+ : []), ...trailerLines.map((line, index) => h(Text, {
22686
23845
  key: `commit-trailer-${index}`,
22687
23846
  dimColor: line.startsWith(' '),
22688
- }, truncate$1(line, width - 4))));
23847
+ }, truncate$1(line, width - 4))), h(Box, { flexGrow: 1 }), loading
23848
+ ? null
23849
+ : h(Text, { key: 'commit-state', dimColor: true }, truncate$1(stateLine, width - 4)));
23850
+ }
23851
+ function renderInputPromptPanel(h, components, state, width, theme, focused) {
23852
+ const { Box, Text } = components;
23853
+ const prompt = state.inputPrompt;
23854
+ if (!prompt) {
23855
+ return h(Box, { width });
23856
+ }
23857
+ return h(Box, {
23858
+ borderColor: focusBorderColor(theme, focused),
23859
+ borderStyle: theme.borderStyle,
23860
+ flexDirection: 'column',
23861
+ width,
23862
+ paddingX: 1,
23863
+ }, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), h(Text, {
23864
+ bold: true,
23865
+ color: theme.noColor ? undefined : theme.colors.accent,
23866
+ }, truncate$1(`${prompt.value}_`, width - 4)), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Enter to submit · Esc to cancel · Ctrl+u to clear'));
22689
23867
  }
22690
23868
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
22691
23869
  const { Box, Text } = components;
@@ -22863,6 +24041,7 @@ function renderFooter(h, components, state, theme, idleTip) {
22863
24041
  const { Box, Text } = components;
22864
24042
  const hints = getLogInkFooterHints({
22865
24043
  activeView: state.activeView,
24044
+ diffSource: state.diffSource,
22866
24045
  filterMode: state.filterMode,
22867
24046
  focus: state.focus,
22868
24047
  pendingKey: state.pendingKey,