git-coco 0.59.0 → 0.60.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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.59.0";
64
+ const BUILD_VERSION = "0.60.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -18903,9 +18903,17 @@ async function getStashOverview(git) {
18903
18903
  // %gd — stash reflog selector (stash@{N})
18904
18904
  // %H — stash commit hash
18905
18905
  // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18906
- // %ci — committer date, ISO format
18906
+ // %cI — committer date, strict ISO 8601
18907
18907
  // %gs — reflog subject ("WIP on main: <subject>")
18908
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
18908
+ //
18909
+ // NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
18910
+ // `%gd` selector from the index form (`stash@{0}`) into a timestamp
18911
+ // (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
18912
+ // row width, and — critically — breaks `renameStash`, which parses the
18913
+ // `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
18914
+ // independent of `--date`, so we get both a clean index ref and a
18915
+ // parseable date.
18916
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
18909
18917
  return {
18910
18918
  stashes: await Promise.all(stashes.map(async (stash) => ({
18911
18919
  ...stash,
@@ -20574,7 +20582,7 @@ function applyCommitComposeAction(state, action) {
20574
20582
  loading: false,
20575
20583
  streamingPreview: undefined,
20576
20584
  pendingAiDraft: action.value,
20577
- message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20585
+ message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
20578
20586
  details: undefined,
20579
20587
  };
20580
20588
  }
@@ -20623,7 +20631,7 @@ function applyCommitComposeAction(state, action) {
20623
20631
  loading: false,
20624
20632
  streamingPreview: undefined,
20625
20633
  pendingAiDraft: action.value,
20626
- message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20634
+ message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
20627
20635
  details: undefined,
20628
20636
  };
20629
20637
  case 'acceptPendingAiDraft':
@@ -22179,6 +22187,25 @@ function getLogInkWorkflowActions() {
22179
22187
  kind: 'destructive',
22180
22188
  requiresConfirmation: true,
22181
22189
  },
22190
+ {
22191
+ // Palette-only create variants (empty `key`): no global hotkey to
22192
+ // collide with `S` / `gZ`, reachable from `:`. Both stash a quick
22193
+ // WIP entry with the requested scope.
22194
+ id: 'stash-staged',
22195
+ key: '',
22196
+ label: 'Stash staged only',
22197
+ description: 'Stash just the staged (index) changes — `git stash push --staged`.',
22198
+ kind: 'normal',
22199
+ requiresConfirmation: false,
22200
+ },
22201
+ {
22202
+ id: 'stash-keep-index',
22203
+ key: '',
22204
+ label: 'Stash keeping index',
22205
+ description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
22206
+ kind: 'normal',
22207
+ requiresConfirmation: false,
22208
+ },
22182
22209
  {
22183
22210
  id: 'remove-worktree',
22184
22211
  key: 'W',
@@ -22690,6 +22717,13 @@ const LOG_INK_KEY_BINDINGS = [
22690
22717
  description: 'Push the stash view (gz; gs is reserved for status).',
22691
22718
  contexts: ['normal'],
22692
22719
  },
22720
+ {
22721
+ id: 'createStash',
22722
+ keys: ['gZ'],
22723
+ label: 'stash changes',
22724
+ description: 'Stash all changes (tracked + untracked) with an optional message — works from any view, including status/diff/compose. Empty message creates a quick WIP stash.',
22725
+ contexts: ['normal'],
22726
+ },
22693
22727
  {
22694
22728
  id: 'navigateWorktrees',
22695
22729
  keys: ['gw'],
@@ -22962,6 +22996,20 @@ const LOG_INK_KEY_BINDINGS = [
22962
22996
  description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
22963
22997
  contexts: ['status'],
22964
22998
  },
22999
+ {
23000
+ id: 'stageAll',
23001
+ keys: ['A'],
23002
+ label: 'stage all',
23003
+ description: 'Stage every change in the worktree (git add -A).',
23004
+ contexts: ['status', 'compose'],
23005
+ },
23006
+ {
23007
+ id: 'stagePathspec',
23008
+ keys: ['+'],
23009
+ label: 'stage paths',
23010
+ description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
23011
+ contexts: ['status', 'compose'],
23012
+ },
22965
23013
  {
22966
23014
  id: 'viewChangelog',
22967
23015
  keys: ['L'],
@@ -23035,6 +23083,19 @@ const GLOBAL_BINDING_IDS = [
23035
23083
  'navigateBack',
23036
23084
  ];
23037
23085
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
23086
+ /**
23087
+ * Narrow single-pane footer budget (#1135). On terminals below the
23088
+ * single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
23089
+ * the snap-back / peek affordance already claim most of an 80-cell row,
23090
+ * so the per-view hint tail and the global cluster are trimmed to what
23091
+ * fits without clipping — the switcher is the orientation anchor and
23092
+ * must stay whole. The dropped bindings remain one `?` (help) away.
23093
+ *
23094
+ * - keep only the first view hint (the most actionable for the view)
23095
+ * - shrink the global cluster to the two recovery essentials
23096
+ */
23097
+ const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
23098
+ const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
23038
23099
  /**
23039
23100
  * Per-binding category mapping. Used to subdivide the help overlay's
23040
23101
  * Global and view sections into named clusters so users don't face a
@@ -23055,6 +23116,9 @@ const BINDING_CATEGORY_BY_ID = {
23055
23116
  openProjectConfig: 'view',
23056
23117
  openGlobalConfig: 'view',
23057
23118
  gitignoreFile: 'mutate',
23119
+ stageAll: 'mutate',
23120
+ stagePathspec: 'mutate',
23121
+ createStash: 'mutate',
23058
23122
  quit: 'essentials',
23059
23123
  refresh: 'essentials',
23060
23124
  navigateBack: 'essentials',
@@ -23206,18 +23270,20 @@ function formatLogInkBreadcrumb(viewStack) {
23206
23270
  if (viewStack.length === 1 && viewStack[0] === 'history') {
23207
23271
  return '';
23208
23272
  }
23209
- // Trailing back-hint (P2.5) reminds the user how to walk back when
23210
- // they're nested deeper than the root view.
23211
- return `${viewStack.join(' › ')} ← <`;
23273
+ // Pure location breadcrumb no trailing back-hint. The footer's
23274
+ // global `< back` hint already names the walk-back key, so repeating
23275
+ // `← <` on every nested view was redundant header chrome (TUI audit).
23276
+ return viewStack.join(' › ');
23212
23277
  }
23213
23278
  /**
23214
23279
  * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
23215
23280
  * for the chrome header. Returns an empty string for a root-only stack
23216
23281
  * so the header stays compact when nothing has been pushed.
23217
23282
  *
23218
- * The trailing `← esc` reminds the user that Esc is the way out — same
23219
- * shape as the view breadcrumb's `← <` so the two read consistently.
23220
- * The repo breadcrumb shows in addition to the view breadcrumb when
23283
+ * The trailing `← esc` reminds the user that Esc (not `<`) pops the
23284
+ * repo stack a distinct key from the footer's global `< back`, so
23285
+ * unlike the view breadcrumb (pure location) the repo crumb keeps its
23286
+ * hint. The repo breadcrumb shows in addition to the view breadcrumb when
23221
23287
  * both stacks are non-trivial; the chrome layer is responsible for
23222
23288
  * laying them out side by side.
23223
23289
  *
@@ -23260,7 +23326,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
23260
23326
  }
23261
23327
  return '';
23262
23328
  }
23329
+ /**
23330
+ * Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
23331
+ * The active pane (derived from focus: sidebar → sidebar, detail →
23332
+ * inspector, otherwise main) is bracketed so the user can see which of
23333
+ * the three panes Tab will move them away from. Surfaced only on narrow
23334
+ * terminals where the other two panes aren't on screen.
23335
+ */
23336
+ function singlePaneSwitcherHint(focus) {
23337
+ const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
23338
+ const label = (pane) => (pane === active ? `[${pane}]` : pane);
23339
+ return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
23340
+ }
23263
23341
  function getLogInkFooterHints(options) {
23342
+ const hints = computeLogInkFooterHints(options);
23343
+ // While peeking the sidebar (#1135 v2) the footer shows the snap-back
23344
+ // affordance instead of the switcher — the user is mid-glance, not
23345
+ // navigating, so `v`/Esc returning to main is the relevant action. The
23346
+ // view-hint tail + globals are trimmed to fit the narrow row (see
23347
+ // SINGLE_PANE_GLOBAL_HINTS).
23348
+ if (options.peeking) {
23349
+ return {
23350
+ contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
23351
+ global: SINGLE_PANE_GLOBAL_HINTS,
23352
+ };
23353
+ }
23354
+ // On narrow terminals only one pane is on screen, so prepend a Tab
23355
+ // pane switcher for orientation. The caller (footer) only sets
23356
+ // `singlePane` in the plain per-pane states — while an overlay or
23357
+ // filter owns the screen the visible pane is forced (or input is
23358
+ // captured) and Tab does something else, so the switcher is
23359
+ // suppressed there to avoid showing a pane that isn't active. From the
23360
+ // main / inspector pane we also surface `v peek` so the momentary
23361
+ // sidebar glance is discoverable. The full per-view hint cluster +
23362
+ // global cluster don't fit alongside the switcher at the 80-col floor,
23363
+ // so both are trimmed (the dropped keys stay reachable via `?`).
23364
+ if (options.singlePane) {
23365
+ const lead = options.focus === 'sidebar'
23366
+ ? [singlePaneSwitcherHint(options.focus)]
23367
+ : [singlePaneSwitcherHint(options.focus), 'v peek'];
23368
+ return {
23369
+ contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
23370
+ global: SINGLE_PANE_GLOBAL_HINTS,
23371
+ };
23372
+ }
23373
+ return hints;
23374
+ }
23375
+ function computeLogInkFooterHints(options) {
23264
23376
  if (options.pendingKey) {
23265
23377
  const continuations = getLogInkChordContinuations(options.pendingKey);
23266
23378
  if (continuations.length > 0) {
@@ -23377,7 +23489,7 @@ function getLogInkFooterHints(options) {
23377
23489
  }
23378
23490
  if (options.activeView === 'status') {
23379
23491
  return {
23380
- contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
23492
+ contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
23381
23493
  global: NORMAL_GLOBAL_HINTS,
23382
23494
  };
23383
23495
  }
@@ -23389,16 +23501,19 @@ function getLogInkFooterHints(options) {
23389
23501
  const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
23390
23502
  if (options.diffSource === 'stash') {
23391
23503
  return {
23392
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
23504
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
23393
23505
  global: NORMAL_GLOBAL_HINTS,
23394
23506
  };
23395
23507
  }
23396
23508
  if (options.diffSource === 'commit') {
23397
23509
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
23398
23510
  // cursored file from the commit into the worktree, and `H`
23399
- // (or `gH` for index) applies just the cursored hunk.
23511
+ // (or `gH` for index) applies just the cursored hunk. `j/k`
23512
+ // line-scroll the diff body; `[`/`]` jump between hunks — the
23513
+ // footer labels match the actual handlers (commit diff has no
23514
+ // per-file `[/]` jump; that's the stash diff).
23400
23515
  return {
23401
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
23516
+ contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
23402
23517
  global: NORMAL_GLOBAL_HINTS,
23403
23518
  };
23404
23519
  }
@@ -23411,14 +23526,17 @@ function getLogInkFooterHints(options) {
23411
23526
  global: NORMAL_GLOBAL_HINTS,
23412
23527
  };
23413
23528
  }
23529
+ // Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
23530
+ // hunks, space stages/unstages the selected one, a stages the whole
23531
+ // file, z discards the hunk.
23414
23532
  return {
23415
- contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
23533
+ contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
23416
23534
  global: NORMAL_GLOBAL_HINTS,
23417
23535
  };
23418
23536
  }
23419
23537
  if (options.activeView === 'compose') {
23420
23538
  return {
23421
- contextual: ['e edit', 'E $EDITOR', 'c commit', 'S split', 'I AI draft', 'gs hunks', 'esc back'],
23539
+ contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
23422
23540
  global: NORMAL_GLOBAL_HINTS,
23423
23541
  };
23424
23542
  }
@@ -23448,7 +23566,7 @@ function getLogInkFooterHints(options) {
23448
23566
  }
23449
23567
  if (options.activeView === 'stash') {
23450
23568
  return {
23451
- contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', 'X drop', 'y yank'],
23569
+ contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
23452
23570
  global: NORMAL_GLOBAL_HINTS,
23453
23571
  };
23454
23572
  }
@@ -24839,7 +24957,7 @@ function topOfStack(stack) {
24839
24957
  }
24840
24958
  function withPushedView(state, value) {
24841
24959
  if (topOfStack(state.viewStack) === value) {
24842
- return { ...state, pendingKey: undefined };
24960
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
24843
24961
  }
24844
24962
  const viewStack = [...state.viewStack, value];
24845
24963
  return {
@@ -24862,12 +24980,15 @@ function withPushedView(state, value) {
24862
24980
  compareHead: value === 'diff' ? state.compareHead : undefined,
24863
24981
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24864
24982
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24983
+ // Changing the view is a deliberate destination — cancel any pending
24984
+ // peek return so the user isn't snapped back afterward.
24985
+ peekReturnFocus: undefined,
24865
24986
  pendingKey: undefined,
24866
24987
  };
24867
24988
  }
24868
24989
  function withPoppedView(state) {
24869
24990
  if (state.viewStack.length <= 1) {
24870
- return { ...state, pendingKey: undefined };
24991
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
24871
24992
  }
24872
24993
  const viewStack = state.viewStack.slice(0, -1);
24873
24994
  const next = topOfStack(viewStack);
@@ -24894,6 +25015,8 @@ function withPoppedView(state) {
24894
25015
  compareHead: next === 'diff' ? state.compareHead : undefined,
24895
25016
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
24896
25017
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
25018
+ // Backing out is a deliberate navigation — cancel any peek return.
25019
+ peekReturnFocus: undefined,
24897
25020
  pendingKey: undefined,
24898
25021
  };
24899
25022
  }
@@ -25006,7 +25129,7 @@ function withPoppedRepoFrame(state) {
25006
25129
  }
25007
25130
  function withReplacedView(state, value) {
25008
25131
  if (topOfStack(state.viewStack) === value) {
25009
- return { ...state, pendingKey: undefined };
25132
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
25010
25133
  }
25011
25134
  const viewStack = [...state.viewStack.slice(0, -1), value];
25012
25135
  return {
@@ -25020,6 +25143,9 @@ function withReplacedView(state, value) {
25020
25143
  compareHead: value === 'diff' ? state.compareHead : undefined,
25021
25144
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
25022
25145
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
25146
+ // Changing the view is a deliberate destination — cancel any pending
25147
+ // peek return so the user isn't snapped back afterward.
25148
+ peekReturnFocus: undefined,
25023
25149
  pendingKey: undefined,
25024
25150
  };
25025
25151
  }
@@ -25253,6 +25379,9 @@ function applyLogInkAction(state, action) {
25253
25379
  // from 'commits' should always land back on a real file when
25254
25380
  // the user returns.
25255
25381
  statusGroupHeaderFocused: false,
25382
+ // Explicit focus cycle cancels a pending peek return — the
25383
+ // user has taken manual control of the focus.
25384
+ peekReturnFocus: undefined,
25256
25385
  pendingKey: undefined,
25257
25386
  };
25258
25387
  case 'focusPrevious':
@@ -25261,6 +25390,7 @@ function applyLogInkAction(state, action) {
25261
25390
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
25262
25391
  sidebarHeaderFocused: false,
25263
25392
  statusGroupHeaderFocused: false,
25393
+ peekReturnFocus: undefined,
25264
25394
  pendingKey: undefined,
25265
25395
  };
25266
25396
  case 'move':
@@ -25758,8 +25888,35 @@ function applyLogInkAction(state, action) {
25758
25888
  // the status view — clear when focus moves away so a
25759
25889
  // re-entry starts on a real file.
25760
25890
  statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
25891
+ // An explicit focus set cancels a pending peek return.
25892
+ peekReturnFocus: undefined,
25893
+ pendingKey: undefined,
25894
+ };
25895
+ case 'togglePeek': {
25896
+ // Peek = "focus the sidebar with a return ticket." Closing returns
25897
+ // to the stashed focus; opening (only from a non-sidebar pane)
25898
+ // stashes the current focus and jumps to the sidebar. The render
25899
+ // layer needs no special case — `focus: 'sidebar'` already drives
25900
+ // the single-pane layout to show the sidebar full-width.
25901
+ if (state.peekReturnFocus !== undefined) {
25902
+ return {
25903
+ ...state,
25904
+ focus: state.peekReturnFocus,
25905
+ peekReturnFocus: undefined,
25906
+ sidebarHeaderFocused: false,
25907
+ pendingKey: undefined,
25908
+ };
25909
+ }
25910
+ if (state.focus === 'sidebar') {
25911
+ return state;
25912
+ }
25913
+ return {
25914
+ ...state,
25915
+ focus: 'sidebar',
25916
+ peekReturnFocus: state.focus,
25761
25917
  pendingKey: undefined,
25762
25918
  };
25919
+ }
25763
25920
  case 'setPendingKey':
25764
25921
  return {
25765
25922
  ...state,
@@ -26626,6 +26783,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
26626
26783
  return [action({ type: 'toggleGraph' })];
26627
26784
  case 'navigateHome':
26628
26785
  return [action({ type: 'navigateHome' })];
26786
+ case 'createStash':
26787
+ return [action({
26788
+ type: 'openInputPrompt',
26789
+ kind: 'create-stash',
26790
+ label: 'Stash message (empty = WIP)',
26791
+ })];
26629
26792
  case 'navigateStatus':
26630
26793
  return [action({ type: 'pushView', value: 'status' })];
26631
26794
  case 'navigateDiff':
@@ -26731,6 +26894,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
26731
26894
  // Runtime resolves the cursored worktree file and opens the picker
26732
26895
  // (no-ops with a warning when there's no file under the cursor).
26733
26896
  return [{ type: 'openGitignorePicker' }];
26897
+ case 'stageAll':
26898
+ return [{ type: 'runWorkflowAction', id: 'stage-all' }];
26899
+ case 'stagePathspec':
26900
+ return [action({
26901
+ type: 'openInputPrompt',
26902
+ kind: 'stage-pathspec',
26903
+ label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
26904
+ })];
26734
26905
  case 'workflowDeleteBranch':
26735
26906
  case 'workflowDeleteTag':
26736
26907
  case 'workflowDropStash':
@@ -26821,6 +26992,15 @@ function submitInputPrompt(state) {
26821
26992
  if (!state.inputPrompt)
26822
26993
  return [];
26823
26994
  const value = state.inputPrompt.value.trim();
26995
+ // create-stash allows an EMPTY value → quick WIP stash (git supplies its
26996
+ // own "WIP on <branch>" subject). Handled before the generic empty guard
26997
+ // so an empty stash prompt commits a WIP stash instead of bouncing.
26998
+ if (state.inputPrompt.kind === 'create-stash') {
26999
+ return [
27000
+ { type: 'runWorkflowAction', id: 'create-stash', payload: value },
27001
+ action({ type: 'closeInputPrompt' }),
27002
+ ];
27003
+ }
26824
27004
  if (!value) {
26825
27005
  return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
26826
27006
  }
@@ -26830,6 +27010,12 @@ function submitInputPrompt(state) {
26830
27010
  action({ type: 'closeInputPrompt' }),
26831
27011
  ];
26832
27012
  }
27013
+ if (state.inputPrompt.kind === 'stage-pathspec') {
27014
+ return [
27015
+ { type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
27016
+ action({ type: 'closeInputPrompt' }),
27017
+ ];
27018
+ }
26833
27019
  if (state.inputPrompt.kind === 'reset-mode') {
26834
27020
  const mode = value.toLowerCase();
26835
27021
  if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
@@ -27056,7 +27242,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27056
27242
  // draft was pending should see the original `R` / Esc semantics of
27057
27243
  // wherever they are now.
27058
27244
  if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
27059
- if (inputValue === 'R' && !key.ctrl && !key.meta) {
27245
+ // `R` or `Enter` accept the swap (the AI draft becomes the new
27246
+ // content); `Enter` is the natural "yes, use it" confirmation.
27247
+ if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
27060
27248
  return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
27061
27249
  }
27062
27250
  if (key.escape) {
@@ -27442,6 +27630,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27442
27630
  }
27443
27631
  return events;
27444
27632
  }
27633
+ // #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
27634
+ // snaps back to the pane the user came from. Placed before the
27635
+ // generic Esc → popView so a peek glance returns to main rather than
27636
+ // walking the view stack. Every other key falls through to normal
27637
+ // handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
27638
+ // browse the sidebar and keep the peek open until an explicit exit.
27639
+ if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
27640
+ return [action({ type: 'togglePeek' })];
27641
+ }
27445
27642
  if (key.escape && state.viewStack.length > 1) {
27446
27643
  return [action({ type: 'popView' })];
27447
27644
  }
@@ -27508,6 +27705,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27508
27705
  action({ type: 'setStatus', value: 'jumped to stash' }),
27509
27706
  ];
27510
27707
  }
27708
+ // `gZ` chord: stash all changes from ANY view — including status / diff /
27709
+ // compose, where bare `S` is claimed by the commit-split flow. Mnemonic
27710
+ // pair with `gz` (jump to the stash *view*). Opens the same message
27711
+ // prompt; an empty message creates a quick WIP stash.
27712
+ if (state.pendingKey === 'g' && inputValue === 'Z') {
27713
+ return [action({
27714
+ type: 'openInputPrompt',
27715
+ kind: 'create-stash',
27716
+ label: 'Stash message (empty = WIP)',
27717
+ })];
27718
+ }
27511
27719
  if (state.pendingKey === 'g' && inputValue === 'w') {
27512
27720
  return [
27513
27721
  action({ type: 'pushView', value: 'worktrees' }),
@@ -27871,6 +28079,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27871
28079
  if (key.tab) {
27872
28080
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
27873
28081
  }
28082
+ // #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
28083
+ // narrow (single-pane) terminals: a momentary glance that snaps back
28084
+ // with `v` / Esc (handled above once peeking). No-op in the three-pane
28085
+ // layout (every pane is already on screen) and from the sidebar itself.
28086
+ if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
28087
+ return [action({ type: 'togglePeek' })];
28088
+ }
27874
28089
  // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
27875
28090
  // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
27876
28091
  // vertical axis (↑/↓ below) is "within the active tab's items".
@@ -27956,10 +28171,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27956
28171
  fileCount: context.worktreeFileCount,
27957
28172
  })];
27958
28173
  }
27959
- // Diff view: j/k scrolls the visible diff one line. Hunk navigation
27960
- // moved to ]/[ so single-hunk files (longer than the preview pane)
27961
- // can scroll bidirectionally instead of getting pinned to a hunk
27962
- // anchor.
28174
+ // Worktree (staging) diff: ↑/↓ move between hunks the hunk is the
28175
+ // unit you stage, so the cursor walks hunks (auto-scrolling to the
28176
+ // selected one). Single-hunk files fall through to line-scroll so a
28177
+ // long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
28178
+ if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
28179
+ return [action({
28180
+ type: 'jumpWorktreeHunk',
28181
+ delta: -1,
28182
+ hunkOffsets: context.worktreeHunkOffsets,
28183
+ })];
28184
+ }
27963
28185
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
27964
28186
  return [action({
27965
28187
  type: 'pageWorktreeDiff',
@@ -28074,6 +28296,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28074
28296
  fileCount: context.worktreeFileCount,
28075
28297
  })];
28076
28298
  }
28299
+ // Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
28300
+ // handler). Multi-hunk only; single-hunk files line-scroll.
28301
+ if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
28302
+ return [action({
28303
+ type: 'jumpWorktreeHunk',
28304
+ delta: 1,
28305
+ hunkOffsets: context.worktreeHunkOffsets,
28306
+ })];
28307
+ }
28077
28308
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
28078
28309
  return [action({
28079
28310
  type: 'pageWorktreeDiff',
@@ -28480,6 +28711,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28480
28711
  if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
28481
28712
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
28482
28713
  }
28714
+ // `A` applies restoring the staged/unstaged split (`git stash apply
28715
+ // --index`) — distinct from `a` (plain apply).
28716
+ if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
28717
+ return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
28718
+ }
28719
+ // `b` turns the cursored stash into a new branch (`git stash branch`).
28720
+ if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
28721
+ return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
28722
+ }
28723
+ // `R` renames the cursored stash (store-under-new-message + drop old).
28724
+ if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
28725
+ return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
28726
+ }
28727
+ // `u` undoes the last drop. Gated on the view, NOT the count, so it
28728
+ // still works right after you drop your only stash (the list is empty
28729
+ // but the dropped commit is recoverable by hash).
28730
+ if (inputValue === 'u' && isStashActionTarget(state)) {
28731
+ return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
28732
+ }
28483
28733
  // Per-view tag action: `P` pushes the selected tag to origin. Letter
28484
28734
  // is scoped to the tags target so it doesn't collide with `p` for
28485
28735
  // pop-stash. Note: this also takes precedence over the global
@@ -28708,7 +28958,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28708
28958
  return [action({
28709
28959
  type: 'openInputPrompt',
28710
28960
  kind: 'create-stash',
28711
- label: 'Stash message',
28961
+ label: 'Stash message (empty = WIP)',
28712
28962
  })];
28713
28963
  }
28714
28964
  // `o` opens the file under the cursor in $EDITOR. Available on the
@@ -28977,9 +29227,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28977
29227
  if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
28978
29228
  return [{ type: 'toggleSelectedFileStage' }];
28979
29229
  }
29230
+ // `A` — stage everything (git add -A); `+` — stage by typed pathspec.
29231
+ // Both available from the status AND compose views so you can stage
29232
+ // without leaving the message editor.
29233
+ if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
29234
+ return [{ type: 'runWorkflowAction', id: 'stage-all' }];
29235
+ }
29236
+ if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
29237
+ return [action({
29238
+ type: 'openInputPrompt',
29239
+ kind: 'stage-pathspec',
29240
+ label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
29241
+ })];
29242
+ }
28980
29243
  if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
28981
29244
  return [{ type: 'toggleSelectedHunkStage' }];
28982
29245
  }
29246
+ // Worktree diff with no hunks (a new/untracked file) — `space` stages
29247
+ // the whole file, since there's nothing to partial-stage.
29248
+ if (inputValue === ' ' &&
29249
+ state.activeView === 'diff' &&
29250
+ state.diffSource === 'worktree' &&
29251
+ !context.worktreeHunkOffsets?.length) {
29252
+ return [{ type: 'toggleSelectedFileStage' }];
29253
+ }
29254
+ // `a` stages/unstages the WHOLE current file from the staging diff —
29255
+ // an escape hatch out of hunk-by-hunk back to all-or-nothing.
29256
+ if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
29257
+ return [{ type: 'toggleSelectedFileStage' }];
29258
+ }
28983
29259
  if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
28984
29260
  return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
28985
29261
  }
@@ -29754,21 +30030,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
29754
30030
  * wide >= 160 — plenty of room; keep absolute dates
29755
30031
  * normal >= 120 — relative dates save 8-ish cells without hiding info
29756
30032
  * tight >= 100 — drop date entirely; subject + refs are the priority
29757
- * rail < 100 — even with side panels collapsed the row is tight;
29758
- * stack to two lines and rail the side panels at rest
30033
+ * rail < 100 — history rows stack to two lines; the UI also drops
30034
+ * to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
29759
30035
  */
29760
30036
  const LAYOUT_TIGHT_BELOW = 120;
29761
30037
  const LAYOUT_NORMAL_BELOW = 160;
29762
30038
  const LAYOUT_RAIL_BELOW = 100;
29763
30039
  /**
29764
- * Fixed cell width for a railed side panel. Just wide enough for a
29765
- * 1-cell icon + a 2-3 digit count after subtracting border (2) and
29766
- * padding (2). Going narrower clips the count; going wider defeats
29767
- * the purpose of railing in the first place.
30040
+ * Width below which the three-panel layout can't tile without starving
30041
+ * every pane, so the UI shows exactly one full-width pane (the focused
30042
+ * one) and Tab cycles which pane is visible. Coincides with the `rail`
30043
+ * density breakpoint single-pane mode replaces the old 8-cell icon
30044
+ * rails that used to render at this width.
29768
30045
  */
29769
- const LAYOUT_RAIL_PANEL_WIDTH = 8;
30046
+ const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
29770
30047
  const SIDEBAR_AT_REST_BY_TIER = {
29771
- rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
30048
+ rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest single-pane mode overrides the width
29772
30049
  tight: { min: 22, max: 28, fraction: 0.24 },
29773
30050
  normal: { min: 22, max: 30, fraction: 0.22 },
29774
30051
  wide: { min: 28, max: 32, fraction: 0.20 },
@@ -29787,14 +30064,25 @@ function getLogInkLayout(input) {
29787
30064
  : columns >= LAYOUT_RAIL_BELOW
29788
30065
  ? 'tight'
29789
30066
  : 'rail';
29790
- // Rail collapse: only happens at the narrowest tier, and only for
29791
- // the panel that does NOT currently hold focus AND is not being
29792
- // commandeered by the help overlay. Focus always wins pressing
29793
- // tab to the sidebar pops it back open even on an 80-cell terminal
29794
- // so the user can actually use it. The help overlay also wins for
29795
- // the inspector since that's where its descriptions render.
29796
- const sidebarRailed = density === 'rail' && !input.sidebarFocused;
29797
- const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
30067
+ // Below the single-pane breakpoint the three-panel layout can't tile
30068
+ // without starving every pane, so we show exactly one full-width pane
30069
+ // the focused one and Tab cycles which pane is visible. This
30070
+ // replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
30071
+ // glyph + count and nothing actionable).
30072
+ const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
30073
+ // Which pane shows in single-pane mode. Defaults to the focused pane
30074
+ // (focus and visibility coalesce, so the existing Tab focus cycle
30075
+ // drives it). An active overlay can force a specific pane via
30076
+ // `forcedPane` so its surface isn't hidden behind whatever pane focus
30077
+ // points at.
30078
+ const focusPane = input.sidebarFocused
30079
+ ? 'sidebar'
30080
+ : input.inspectorFocused
30081
+ ? 'inspector'
30082
+ : 'main';
30083
+ const visiblePane = singlePane
30084
+ ? input.forcedPane ?? focusPane
30085
+ : focusPane;
29798
30086
  // Inspector width — at rest 20-32 cells (~22% of width), focused
29799
30087
  // 36-60 cells (~40% of width). Narrow rest state keeps the commit
29800
30088
  // graph dominant; focus expansion gives the inspector room for long
@@ -29806,42 +30094,48 @@ function getLogInkLayout(input) {
29806
30094
  // "Move focus...". Capped at 100 cells so a wide terminal doesn't
29807
30095
  // waste an absurd amount of horizontal space on the cheat sheet.
29808
30096
  //
29809
- // Rail collapse wins over the at-rest range but loses to focus and
29810
- // to the help overlay both of those represent deliberate user
29811
- // intent to read the panel.
30097
+ // (In single-pane mode these three-panel widths are recomputed below
30098
+ // so the visible pane gets the full terminal.)
29812
30099
  const detailWidth = input.helpOverlayActive
29813
30100
  ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
29814
30101
  : input.inspectorFocused
29815
30102
  ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
29816
- : inspectorRailed
29817
- ? LAYOUT_RAIL_PANEL_WIDTH
29818
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
30103
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
29819
30104
  // Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
29820
30105
  // tight stays compact (22-28), normal shrinks slightly (22-30),
29821
30106
  // wide grows naturally (28-48) so the side panel doesn't get pinned
29822
30107
  // at an arbitrary cap on big terminals while the main panel hogs
29823
30108
  // 80% of the width. Focused: 32-50 cells (~36% of width),
29824
30109
  // regardless of tier — deliberate user intent to read the sidebar
29825
- // deserves the extra width. Rail mode (narrow terminal, unfocused)
29826
- // collapses to a fixed 8-cell strip with tab glyphs only.
30110
+ // deserves the extra width.
29827
30111
  const sidebarWidth = input.sidebarFocused
29828
30112
  ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
29829
- : sidebarRailed
29830
- ? LAYOUT_RAIL_PANEL_WIDTH
29831
- : calcSidebarAtRestWidth(columns, density);
30113
+ : calcSidebarAtRestWidth(columns, density);
30114
+ // Single-pane mode: exactly one pane renders, full-width; the other
30115
+ // two are hidden (width 0), not railed. Above the breakpoint the
30116
+ // three panels tile flush across the terminal.
30117
+ const paneWidths = singlePane
30118
+ ? {
30119
+ sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
30120
+ mainPanelWidth: visiblePane === 'main' ? columns : 0,
30121
+ detailWidth: visiblePane === 'inspector' ? columns : 0,
30122
+ }
30123
+ : {
30124
+ sidebarWidth,
30125
+ mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
30126
+ detailWidth,
30127
+ };
29832
30128
  return {
29833
30129
  bodyRows: Math.max(8, rows - 5),
29834
30130
  columns,
29835
- detailWidth,
29836
- mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
29837
30131
  rows,
29838
- sidebarWidth,
29839
30132
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
29840
30133
  inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
29841
30134
  density,
29842
- sidebarRailed,
29843
- inspectorRailed,
30135
+ singlePane,
30136
+ visiblePane,
29844
30137
  historyRowMode: density === 'rail' ? 'stacked' : 'single',
30138
+ ...paneWidths,
29845
30139
  };
29846
30140
  }
29847
30141
 
@@ -30890,6 +31184,45 @@ async function highlightDiffCode(filePath, lines) {
30890
31184
  return result;
30891
31185
  }
30892
31186
 
31187
+ /**
31188
+ * Humanize raw AI-provider / LangChain error strings into a short,
31189
+ * actionable line for the compose surface.
31190
+ *
31191
+ * The underlying errors are verbose and developer-facing — e.g.
31192
+ * `executeChain: Chain execution failed: 429 You exceeded your current
31193
+ * quota …`. We classify the common failure modes (rate limit, auth,
31194
+ * network, context length) into a concise message that tells the user
31195
+ * what happened and what to do, and fall back to the original (trimmed)
31196
+ * text for anything we don't recognize. Pure + tested.
31197
+ */
31198
+ function humanizeAiError(raw) {
31199
+ const message = (raw || '').trim();
31200
+ if (!message)
31201
+ return 'AI request failed.';
31202
+ const lower = message.toLowerCase();
31203
+ // Rate limit / quota — the 429 in the screenshot.
31204
+ if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
31205
+ return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
31206
+ }
31207
+ // Auth / API key problems.
31208
+ if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
31209
+ return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
31210
+ }
31211
+ // Context window overflow.
31212
+ if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
31213
+ return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
31214
+ }
31215
+ // Network / connectivity.
31216
+ if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
31217
+ return 'Network error reaching the AI provider — check your connection, then press I to retry.';
31218
+ }
31219
+ // Unknown: strip the noisy `executeChain: Chain execution failed:`
31220
+ // prefix if present so the meaningful part leads, and keep it to one
31221
+ // line so it doesn't blow out the panel.
31222
+ const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
31223
+ return stripped.split('\n')[0];
31224
+ }
31225
+
30893
31226
  async function runAction$4(action, successMessage) {
30894
31227
  try {
30895
31228
  await action();
@@ -30933,15 +31266,104 @@ async function runAction$3(action, successMessage) {
30933
31266
  };
30934
31267
  }
30935
31268
  }
30936
- function createStash(git, message) {
31269
+ function createStash(git, message, options = {}) {
30937
31270
  const trimmedMessage = message.trim();
30938
- if (!trimmedMessage) {
30939
- return Promise.resolve({
30940
- ok: false,
30941
- message: 'Stash cancelled: empty message.',
30942
- });
31271
+ const args = ['stash', 'push'];
31272
+ // `--staged` is index-only, so untracked / `--keep-index` don't apply;
31273
+ // every other mode includes untracked (`-u`). `--keep-index` leaves the
31274
+ // index populated for an immediate follow-up commit.
31275
+ if (options.stagedOnly) {
31276
+ args.push('--staged');
31277
+ }
31278
+ else {
31279
+ args.push('-u');
31280
+ if (options.keepIndex)
31281
+ args.push('--keep-index');
31282
+ }
31283
+ if (trimmedMessage)
31284
+ args.push('-m', trimmedMessage);
31285
+ const paths = options.pathspec?.trim();
31286
+ if (paths)
31287
+ args.push('--', ...paths.split(/\s+/));
31288
+ const what = options.stagedOnly
31289
+ ? 'staged changes'
31290
+ : paths
31291
+ ? `“${paths}”`
31292
+ : options.keepIndex
31293
+ ? 'changes (index kept)'
31294
+ : '';
31295
+ const success = trimmedMessage
31296
+ ? `Created stash: ${trimmedMessage}`
31297
+ : what
31298
+ ? `Stashed ${what}`
31299
+ : 'Created WIP stash';
31300
+ return runAction$3(() => git.raw(args), success);
31301
+ }
31302
+ /**
31303
+ * Apply a stash while restoring the original staged/unstaged split via
31304
+ * `--index`. Faithfully reinstates what was staged at stash time; git
31305
+ * errors (surfaced to the user) if the index can no longer be replayed,
31306
+ * in which case plain `applyStash` is the fallback.
31307
+ */
31308
+ function applyStashKeepIndex(git, stash) {
31309
+ return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
31310
+ }
31311
+ /**
31312
+ * Create a new branch from a stash's base commit, apply the stash onto
31313
+ * it, and drop the stash on success — `git stash branch`. The canonical
31314
+ * recovery when a stash no longer applies cleanly onto the current
31315
+ * branch (the branch starts at the exact commit the stash was made on).
31316
+ */
31317
+ function stashBranch(git, stash, branchName) {
31318
+ const trimmed = branchName.trim();
31319
+ if (!trimmed) {
31320
+ return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
31321
+ }
31322
+ return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
31323
+ }
31324
+ /**
31325
+ * Rename a stash. Git has no native rename, so: drop the original entry,
31326
+ * then re-store the SAME commit under the new message.
31327
+ *
31328
+ * Order matters — and it's the OPPOSITE of what you'd guess. `git stash
31329
+ * store` SILENTLY NO-OPS when the commit is already referenced in the
31330
+ * stash reflog (verified empirically), so storing first does nothing and
31331
+ * a follow-up drop removes the wrong entry. Dropping first removes the
31332
+ * reflog reference (the commit object survives), so the subsequent
31333
+ * `store` actually re-adds it — landing at `stash@{0}` with the new
31334
+ * message. The commit is captured by hash beforehand, so the drop→store
31335
+ * window can't lose it.
31336
+ */
31337
+ function renameStash(git, stash, newMessage) {
31338
+ const trimmed = newMessage.trim();
31339
+ if (!trimmed) {
31340
+ return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
31341
+ }
31342
+ if (!stash.hash) {
31343
+ return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
31344
+ }
31345
+ // Preserve git's `On <branch>: <subject>` convention so the renamed
31346
+ // stash keeps its origin-branch context. The list + inspector parse the
31347
+ // branch out of that prefix (`parseStashSubject`); a bare message would
31348
+ // render `on <unknown>`. Falls back to the bare message when the branch
31349
+ // is unknown so we never store a misleading `On <unknown>:`.
31350
+ const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
31351
+ const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
31352
+ return runAction$3(async () => {
31353
+ await git.raw(['stash', 'drop', stash.ref]);
31354
+ await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
31355
+ }, `Renamed ${stash.ref} → ${trimmed}`);
31356
+ }
31357
+ /**
31358
+ * Re-store a previously dropped stash by its commit hash — the undo for
31359
+ * a `dropStash`. The dropped stash's commit stays in the object database
31360
+ * until git gc, so storing it back recreates the entry (at `stash@{0}`).
31361
+ */
31362
+ function restoreStash(git, hash, message) {
31363
+ if (!hash) {
31364
+ return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
30943
31365
  }
30944
- return runAction$3(() => git.raw(['stash', 'push', '-u', '-m', trimmedMessage]), `Created stash: ${trimmedMessage}`);
31366
+ return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
30945
31367
  }
30946
31368
  function applyStash(git, stash) {
30947
31369
  return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
@@ -31814,6 +32236,28 @@ function unstageAllFiles(git, files) {
31814
32236
  }
31815
32237
  return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
31816
32238
  }
32239
+ /**
32240
+ * Stage everything in the worktree — modifications, new files, and
32241
+ * deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
32242
+ * palette's "stage all" both route here.
32243
+ */
32244
+ function stageAll(git) {
32245
+ return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
32246
+ }
32247
+ /**
32248
+ * Stage files matching one or more git pathspecs (`git add -- <spec…>`).
32249
+ * Powers the typed "stage…" prompt (`+`): the user types a path, a
32250
+ * directory, a glob like `*.ts`, or a space-separated list, and git's
32251
+ * own pathspec matching does the rest. Args are passed directly (no
32252
+ * shell), so the globs are interpreted by git, not the shell.
32253
+ */
32254
+ function stagePathspec(git, pathspec) {
32255
+ const specs = pathspec.trim().split(/\s+/).filter(Boolean);
32256
+ if (specs.length === 0) {
32257
+ return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
32258
+ }
32259
+ return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
32260
+ }
31817
32261
 
31818
32262
  function hunkHeader(hunk) {
31819
32263
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -32390,7 +32834,7 @@ function buildLoadedHashSet(commits) {
32390
32834
  * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
32391
32835
  * colors + glyphs added in the same pass.
32392
32836
  */
32393
- function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
32837
+ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
32394
32838
  const { Box, Text } = components;
32395
32839
  // Sidebar item count drives the per-tab footer hints — when items are
32396
32840
  // present the footer surfaces in-sidebar ops (checkout / apply / pop /
@@ -32404,6 +32848,23 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32404
32848
  default: return undefined;
32405
32849
  }
32406
32850
  })();
32851
+ // The single-pane pane switcher only makes sense in the plain
32852
+ // per-pane states. While an overlay or filter owns the screen the
32853
+ // visible pane is forced (split-plan → main; help / palette / theme /
32854
+ // gitignore / input prompt / confirmation / chord → inspector) or
32855
+ // input is captured, and Tab does something else — so the switcher
32856
+ // would point at a pane that isn't on screen. Suppress it then. Mirror
32857
+ // of the runtime's `forcedPane` derivation in `app.ts`.
32858
+ const overlayForcesPane = Boolean(state.splitPlan ||
32859
+ state.showHelp ||
32860
+ state.showCommandPalette ||
32861
+ state.showThemePicker ||
32862
+ state.gitignorePicker ||
32863
+ state.inputPrompt ||
32864
+ state.pendingConfirmationId ||
32865
+ state.pendingMutationConfirmation ||
32866
+ state.pendingKey ||
32867
+ state.filterMode);
32407
32868
  const hints = getLogInkFooterHints({
32408
32869
  activeView: state.activeView,
32409
32870
  diffSource: state.diffSource,
@@ -32417,6 +32878,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32417
32878
  sidebarItemCount,
32418
32879
  compareBaseSet: Boolean(state.compareBase),
32419
32880
  splitPlanStatus: state.splitPlan?.status,
32881
+ singlePane: singlePane && !overlayForcesPane,
32882
+ // Peeking (#1135 v2) is a single-pane glance with focus on the
32883
+ // sidebar; the footer shows `v/esc → main` instead of the switcher.
32884
+ // Suppressed under an overlay (which owns the footer) just like the
32885
+ // switcher.
32886
+ peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
32420
32887
  });
32421
32888
  // Real status messages always win; idle tips only fill the slot when it
32422
32889
  // would otherwise be empty.
@@ -32912,7 +33379,10 @@ function sidebarTabCount(tab, context) {
32912
33379
  * Header chip builder. Turns the workstation's title-bar state into an
32913
33380
  * ordered list of small visually-distinct chips:
32914
33381
  *
32915
- * coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
33382
+ * coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
33383
+ *
33384
+ * The PR chip is appended only when a pull request exists (#1133); there
33385
+ * is no "no PR" placeholder chip.
32916
33386
  *
32917
33387
  * Pre-refactor the title bar concatenated every segment into a single
32918
33388
  * Text span, which made the eye read the whole thing as one run of
@@ -33008,10 +33478,11 @@ function buildHeaderChips(input) {
33008
33478
  bold: true,
33009
33479
  });
33010
33480
  }
33011
- // PR state. When present, the chip uses the PR-state glyph + a short
33012
- // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
33013
- // "no PR" chip so users know the system DID look (vs. the bar just
33014
- // being blank).
33481
+ // PR state. Shown only when a PR actually exists the chip uses the
33482
+ // PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
33483
+ // The old always-on "no PR" chip spent a permanent header segment to
33484
+ // report a negative default state on every screen; dropping it keeps
33485
+ // the state cluster about what *is* true (TUI audit).
33015
33486
  if (input.pullRequest) {
33016
33487
  const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
33017
33488
  const stateLabel = input.pullRequest.isDraft
@@ -33028,15 +33499,6 @@ function buildHeaderChips(input) {
33028
33499
  bold: false,
33029
33500
  });
33030
33501
  }
33031
- else {
33032
- chips.push({
33033
- id: 'pr',
33034
- label: theme.ascii ? '- no PR' : '⊘ no PR',
33035
- color: theme.colors.muted,
33036
- dim: true,
33037
- bold: false,
33038
- });
33039
- }
33040
33502
  // View breadcrumb. Rendered only when there's content (`coco ui`
33041
33503
  // root view → no breadcrumb chip; pushed into a sub-view → chip
33042
33504
  // appears). Comes AFTER PR so the "state" group (app/repo/branch/
@@ -33107,7 +33569,10 @@ function measureHeaderChipsWidth(chips) {
33107
33569
  * Title-bar renderer. Surfaces the workstation's identity + navigation
33108
33570
  * state as a row of small visually-distinct chips:
33109
33571
  *
33110
- * coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
33572
+ * coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
33573
+ *
33574
+ * The PR chip is appended only when a pull request exists (e.g.
33575
+ * `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
33111
33576
  *
33112
33577
  * Per-chip color/glyph treatment lets the user scan in chunks ("what
33113
33578
  * app, what repo, what branch, how clean, what PR state, what mode")
@@ -33547,70 +34012,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
33547
34012
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
33548
34013
  }, 'tab-worktrees', visibleListCount);
33549
34014
  }
33550
- /**
33551
- * Single-letter glyph for a sidebar tab in rail mode. Letters always
33552
- * carry the meaning so this stays useful under ASCII; the rail is too
33553
- * narrow to fit the full tab label. Pairs with `sidebarTabCount` for
33554
- * the trailing count.
33555
- */
33556
- function sidebarTabRailGlyph(tab) {
33557
- switch (tab) {
33558
- case 'status':
33559
- return 'S';
33560
- case 'branches':
33561
- return 'B';
33562
- case 'tags':
33563
- return 'T';
33564
- case 'stashes':
33565
- return '$';
33566
- case 'worktrees':
33567
- return 'W';
33568
- default:
33569
- return '·';
33570
- }
33571
- }
33572
- /**
33573
- * Rail-mode sidebar — shown on terminals < 100 columns when the
33574
- * sidebar does not hold focus. Five vertically stacked tab glyphs
33575
- * with optional counts; the active tab is bracketed. Pressing Tab to
33576
- * focus the sidebar pops it back to the full accordion (the layout
33577
- * un-rails it on focus, this renderer is never called in that case).
33578
- */
33579
- function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
33580
- const { Box, Text } = components;
33581
- return h(Box, {
33582
- borderColor: focusBorderColor(theme, focused),
33583
- borderStyle: theme.borderStyle,
33584
- flexDirection: 'column',
33585
- width,
33586
- paddingX: 1,
33587
- }, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
33588
- const isActive = tab === state.sidebarTab;
33589
- const glyph = sidebarTabRailGlyph(tab);
33590
- const count = sidebarTabCount(tab, context);
33591
- // Count fits in 2 cells (rail content area is ~4 cells); 99+
33592
- // collapses to `+` so we never overflow.
33593
- const countText = count === undefined
33594
- ? ''
33595
- : count > 99
33596
- ? '+'
33597
- : String(count);
33598
- const body = isActive ? `[${glyph}]` : ` ${glyph} `;
33599
- const text = countText ? `${body}${countText}` : body;
33600
- return h(Text, {
33601
- key: `rail-${tab}`,
33602
- bold: isActive,
33603
- dimColor: !isActive,
33604
- }, text);
33605
- }));
33606
- }
33607
- function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
34015
+ function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
33608
34016
  const { Box, Text } = components;
33609
34017
  const focused = state.focus === 'sidebar';
33610
34018
  const tabs = getLogInkSidebarTabs();
33611
- if (railed) {
33612
- return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
33613
- }
33614
34019
  // Accordion layout — every tab's title is visible on its own line, but
33615
34020
  // only the active tab expands its content underneath. Switching tabs
33616
34021
  // (1-5 / [/]) collapses the previous and expands the next.
@@ -33669,7 +34074,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
33669
34074
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
33670
34075
  * of #890. No behavior change.
33671
34076
  */
33672
- function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
34077
+ function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
34078
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
33673
34079
  const { Box, Text } = components;
33674
34080
  const focused = state.focus === 'commits';
33675
34081
  const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
@@ -33993,7 +34399,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
33993
34399
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
33994
34400
  * of #890. No behavior change.
33995
34401
  */
33996
- function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
34402
+ function renderBranchesSurface(ctx) {
34403
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
33997
34404
  const { Box, Text } = components;
33998
34405
  const focused = state.focus === 'commits';
33999
34406
  const branches = context.branches;
@@ -34139,7 +34546,8 @@ function formatCacheAge(generatedAt, now) {
34139
34546
  const day = Math.floor(hr / 24);
34140
34547
  return `${day}d ago`;
34141
34548
  }
34142
- function renderChangelogSurface(h, components, state, _context, _contextStatus, bodyRows, width, theme) {
34549
+ function renderChangelogSurface(ctx) {
34550
+ const { h, components, state, bodyRows, width, theme } = ctx;
34143
34551
  const { Box, Text } = components;
34144
34552
  const focused = state.focus === 'commits';
34145
34553
  const view = state.changelogView;
@@ -34331,7 +34739,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
34331
34739
  }, `${prefix}${line}`);
34332
34740
  });
34333
34741
  }
34334
- function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
34742
+ function renderComposeSurface(ctx, spinnerFrame = 0) {
34743
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34335
34744
  const { Box, Text } = components;
34336
34745
  const compose = state.commitCompose;
34337
34746
  const focused = state.focus === 'commits';
@@ -34444,7 +34853,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
34444
34853
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
34445
34854
  * of #890. No behavior change.
34446
34855
  */
34447
- function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
34856
+ function renderConflictsSurface(ctx) {
34857
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34448
34858
  const { Box, Text } = components;
34449
34859
  const focused = state.focus === 'commits';
34450
34860
  const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
@@ -34913,6 +35323,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34913
35323
  return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
34914
35324
  }
34915
35325
 
35326
+ /** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
35327
+ function hunkIndexForLine(absLine, hunkOffsets) {
35328
+ let index = -1;
35329
+ for (let k = 0; k < hunkOffsets.length; k++) {
35330
+ if (hunkOffsets[k] <= absLine)
35331
+ index = k;
35332
+ else
35333
+ break;
35334
+ }
35335
+ return index;
35336
+ }
35337
+ function renderWorktreeDiffBody(h, components, params) {
35338
+ const { Box, Text } = components;
35339
+ const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
35340
+ const headerSet = new Set(hunkOffsets);
35341
+ const accent = theme.noColor ? undefined : theme.colors.accent;
35342
+ const added = theme.noColor ? undefined : theme.colors.gitAdded;
35343
+ const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
35344
+ const visible = lines.slice(offset, offset + visibleRows);
35345
+ return visible.map((line, i) => {
35346
+ const abs = offset + i;
35347
+ const key = `${keyPrefix}-${abs}`;
35348
+ const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
35349
+ const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
35350
+ const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
35351
+ const isStaged = hunk?.state === 'staged';
35352
+ const bar = isSelected ? '▎' : ' ';
35353
+ // `@@` header row — badge + (dim) hunk position, emphasized when selected.
35354
+ if (headerSet.has(abs)) {
35355
+ const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
35356
+ const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
35357
+ return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), h(Text, { color: badgeColor, bold: isSelected }, badge), h(Text, { bold: isSelected, color: isSelected ? accent : (theme.noColor ? undefined : theme.colors.muted) }, truncateCells(line, codeWidth)));
35358
+ }
35359
+ // Body / context / pre-hunk lines.
35360
+ // A staged hunk that ISN'T selected renders dim ("done", out of
35361
+ // focus); the selected hunk and unstaged hunks keep full diff +
35362
+ // syntax coloring via renderDiffLine so the focus stays vivid.
35363
+ const content = isStaged && !isSelected && hunkIndex >= 0
35364
+ ? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
35365
+ : renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
35366
+ return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
35367
+ });
35368
+ }
35369
+
34916
35370
  /**
34917
35371
  * Diff surface — the unified or side-by-side diff view. Four sources
34918
35372
  * route through here, disambiguated by `state.diffSource`:
@@ -34935,7 +35389,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34935
35389
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
34936
35390
  * of #890. No behavior change.
34937
35391
  */
34938
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
35392
+ function renderDiffSurface(ctx, diff) {
35393
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
35394
+ const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
34939
35395
  const { Box, Text } = components;
34940
35396
  const focused = state.focus === 'commits';
34941
35397
  const worktree = context.worktree;
@@ -35106,7 +35562,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35106
35562
  ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
35107
35563
  : previewHunks.length
35108
35564
  ? [
35109
- `Selected file: ${selectedDetailFile?.path || ''}`,
35565
+ // File path is already shown in the panel title bar (right) —
35566
+ // no redundant "Selected file:" line here.
35110
35567
  currentHunkLabel,
35111
35568
  `Lines ${Math.min(state.diffPreviewOffset + 1, previewHunks.length || 1)}-${Math.min(state.diffPreviewOffset + visiblePreviewHunks.length, previewHunks.length)}/${previewHunks.length}`,
35112
35569
  '',
@@ -35134,19 +35591,31 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35134
35591
  }
35135
35592
  const diffLines = worktreeDiff?.lines || [];
35136
35593
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
35594
+ const totalHunks = worktreeHunks?.hunks.length ?? 0;
35595
+ const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
35137
35596
  const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
35597
+ // Hunk-position line: badge + selected hunk's state + a staged/total
35598
+ // progress count, so the user always sees how far through staging they
35599
+ // are. Untracked/new files have no hunks — point them at whole-file
35600
+ // staging instead of a dead-end "no hunks" message.
35601
+ const hunkHeaderLine = worktreeHunksLoading
35602
+ ? 'Hunks loading…'
35603
+ : worktreeDiff?.untracked
35604
+ ? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
35605
+ : totalHunks
35606
+ ? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
35607
+ ? (theme.ascii ? '[x] staged' : '● staged')
35608
+ : (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
35609
+ : 'No stageable hunks for this file.';
35138
35610
  const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
35139
35611
  ? ['Loading file context...']
35140
35612
  : worktreeDiffLoading
35141
35613
  ? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
35142
35614
  : worktreeFile
35143
35615
  ? [
35144
- `Selected file: ${worktreeFile.path}`,
35145
- worktreeHunksLoading
35146
- ? 'Hunks loading...'
35147
- : worktreeHunks?.hunks.length
35148
- ? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
35149
- : 'No stageable hunks for this file.',
35616
+ // File path is already shown in the panel title bar (right) —
35617
+ // no redundant "Selected file:" line here.
35618
+ hunkHeaderLine,
35150
35619
  `Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
35151
35620
  '',
35152
35621
  ]
@@ -35161,11 +35630,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35161
35630
  flexShrink: 0,
35162
35631
  paddingX: 1,
35163
35632
  width,
35164
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)), h(Text, { dimColor: true }, worktreeFile ? worktreeFile.path : 'no file')), ...headerLines.map((line, index) => h(Text, {
35633
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
35634
+ // Use the path of the file actually being diffed (the grouped/visible
35635
+ // selection feeds the loaded diff) — `worktreeFile` indexes the raw,
35636
+ // ungrouped file list and can name a different file than the diff body.
35637
+ h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
35165
35638
  key: `diff-surface-header-${index}`,
35166
35639
  dimColor: index > 0,
35167
35640
  }, truncateCells(line, 140))), ...(showDiffLines
35168
- ? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
35641
+ ? renderWorktreeDiffBody(h, components, {
35642
+ lines: diffLines,
35643
+ offset: state.worktreeDiffOffset,
35644
+ visibleRows,
35645
+ width,
35646
+ theme,
35647
+ syntaxSpans,
35648
+ hunkOffsets: worktreeDiff?.hunkOffsets || [],
35649
+ hunks: worktreeHunks?.hunks || [],
35650
+ selectedIndex: state.selectedWorktreeHunkIndex,
35651
+ keyPrefix: 'diff-surface-line',
35652
+ })
35169
35653
  : []));
35170
35654
  }
35171
35655
 
@@ -36349,7 +36833,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
36349
36833
  height: innerHeight,
36350
36834
  }, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
36351
36835
  }
36352
- function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
36836
+ function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
36837
+ const { h, components, state, context, bodyRows, width, theme } = ctx;
36353
36838
  const { Box, Text } = components;
36354
36839
  const focused = state.focus === 'commits';
36355
36840
  // Remote op in flight (fetch / pull / push) → swap the commit list
@@ -37051,7 +37536,8 @@ function matchesIssueFilter(issue, filter) {
37051
37536
  ...(issue.assignees || []),
37052
37537
  ], filter);
37053
37538
  }
37054
- function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
37539
+ function renderIssuesTriageSurface(ctx) {
37540
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37055
37541
  const { Box, Text } = components;
37056
37542
  const focused = state.focus === 'commits';
37057
37543
  const overview = context.issueList;
@@ -37333,7 +37819,8 @@ function formatPullRequestStateLine(pr) {
37333
37819
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
37334
37820
  * of #890. No behavior change.
37335
37821
  */
37336
- function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
37822
+ function renderPullRequestSurface(ctx) {
37823
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37337
37824
  const { Box, Text } = components;
37338
37825
  const focused = state.focus === 'commits';
37339
37826
  const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
@@ -37506,7 +37993,8 @@ function matchesPullRequestFilter(pr, filter) {
37506
37993
  ...(pr.assignees || []),
37507
37994
  ], filter);
37508
37995
  }
37509
- function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
37996
+ function renderPullRequestTriageSurface(ctx) {
37997
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37510
37998
  const { Box, Text } = components;
37511
37999
  const focused = state.focus === 'commits';
37512
38000
  const overview = context.pullRequestList;
@@ -37622,7 +38110,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
37622
38110
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
37623
38111
  * of #890. No behavior change.
37624
38112
  */
37625
- function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38113
+ function renderReflogSurface(ctx) {
38114
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37626
38115
  const { Box, Text } = components;
37627
38116
  const focused = state.focus === 'commits';
37628
38117
  const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
@@ -37693,7 +38182,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
37693
38182
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
37694
38183
  * of #890. No behavior change.
37695
38184
  */
37696
- function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38185
+ function renderStashSurface(ctx) {
38186
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37697
38187
  const { Box, Text } = components;
37698
38188
  const focused = state.focus === 'commits';
37699
38189
  const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
@@ -37711,6 +38201,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
37711
38201
  : `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
37712
38202
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
37713
38203
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38204
+ const now = getRenderNow();
38205
+ // Available width for a row: box width minus the 2-cell horizontal
38206
+ // padding. Truncate to it (with a small floor) instead of a magic 140
38207
+ // so the richer meta degrades gracefully on narrow terminals.
38208
+ const rowWidth = Math.max(20, width - 2);
37714
38209
  const lines = loading
37715
38210
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
37716
38211
  : stashes.length === 0
@@ -37719,11 +38214,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
37719
38214
  const index = startIndex + offset;
37720
38215
  const isSelected = index === selected;
37721
38216
  const cursor = isSelected ? '>' : ' ';
38217
+ // Surface the metadata the StashEntry already carries — origin
38218
+ // branch, file count, and relative age — between the ref and the
38219
+ // message, so the list answers "which stash is this?" without an
38220
+ // Enter→diff round trip.
38221
+ const age = formatCompactRelativeDate(stash.date, now);
38222
+ const fileCount = stash.files.length;
38223
+ const meta = [
38224
+ stash.branch ? `on ${stash.branch}` : '',
38225
+ fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38226
+ age,
38227
+ ].filter(Boolean).join(' · ');
38228
+ const rowText = meta
38229
+ ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38230
+ : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
37722
38231
  return h(Text, {
37723
38232
  key: `stash-${index}`,
37724
38233
  bold: isSelected,
37725
38234
  dimColor: !isSelected,
37726
- }, truncateCells(`${cursor} ${stash.ref.padEnd(12)} ${stash.message}`, 140));
38235
+ }, truncateCells(rowText, rowWidth));
37727
38236
  });
37728
38237
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
37729
38238
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -37783,7 +38292,8 @@ function formatStatusFilterMask(mask) {
37783
38292
  active.push('untracked');
37784
38293
  return active.join(' + ') || 'none';
37785
38294
  }
37786
- function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38295
+ function renderStatusSurface(ctx) {
38296
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37787
38297
  const { Box, Text } = components;
37788
38298
  const focused = state.focus === 'commits';
37789
38299
  const worktree = context.worktree;
@@ -37930,7 +38440,8 @@ function flagColor(flag, theme) {
37930
38440
  return theme.colors.danger;
37931
38441
  return undefined;
37932
38442
  }
37933
- function renderSubmodulesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38443
+ function renderSubmodulesSurface(ctx) {
38444
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37934
38445
  const { Box, Text } = components;
37935
38446
  const focused = state.focus === 'commits';
37936
38447
  const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
@@ -38069,7 +38580,8 @@ function formatHyperlink(text, url, env = process.env) {
38069
38580
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
38070
38581
  * of #890. No behavior change.
38071
38582
  */
38072
- function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38583
+ function renderTagsSurface(ctx) {
38584
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38073
38585
  const { Box, Text } = components;
38074
38586
  const focused = state.focus === 'commits';
38075
38587
  const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
@@ -38151,7 +38663,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
38151
38663
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38152
38664
  * of #890. No behavior change.
38153
38665
  */
38154
- function renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38666
+ function renderWorktreesSurface(ctx) {
38667
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38155
38668
  const { Box, Text } = components;
38156
38669
  const focused = state.focus === 'commits';
38157
38670
  const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
@@ -38215,7 +38728,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
38215
38728
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
38216
38729
  * of #890. No behavior change.
38217
38730
  */
38218
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
38731
+ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
38732
+ // The universal render values now arrive bundled (#1136); only the
38733
+ // few raw values the dispatcher itself touches (split-plan overlay,
38734
+ // activeView switch) are destructured here. Surfaces receive `surface`
38735
+ // directly plus their own slices.
38736
+ const { h, components, state, bodyRows, width, theme } = surface;
38219
38737
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
38220
38738
  // detail) when active, because the content — multiple commit groups
38221
38739
  // with file lists, rationale, hunks — needs the full center width
@@ -38227,51 +38745,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
38227
38745
  return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
38228
38746
  }
38229
38747
  if (state.activeView === 'status') {
38230
- return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38748
+ return renderStatusSurface(surface);
38231
38749
  }
38232
38750
  if (state.activeView === 'diff') {
38233
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
38751
+ const diffData = {
38752
+ worktreeDiff,
38753
+ worktreeDiffLoading,
38754
+ worktreeHunks,
38755
+ worktreeHunksLoading,
38756
+ filePreview,
38757
+ filePreviewLoading,
38758
+ commitDiffHunkOffsets,
38759
+ selectedDetailFile,
38760
+ stashDiffLines,
38761
+ stashDiffLoading,
38762
+ compareDiffLines,
38763
+ compareDiffLoading,
38764
+ syntaxSpans,
38765
+ };
38766
+ return renderDiffSurface(surface, diffData);
38234
38767
  }
38235
38768
  if (state.activeView === 'compose') {
38236
- return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
38769
+ return renderComposeSurface(surface, spinnerFrame);
38237
38770
  }
38238
38771
  if (state.activeView === 'branches') {
38239
- return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38772
+ return renderBranchesSurface(surface);
38240
38773
  }
38241
38774
  if (state.activeView === 'tags') {
38242
- return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38775
+ return renderTagsSurface(surface);
38243
38776
  }
38244
38777
  if (state.activeView === 'reflog') {
38245
- return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38778
+ return renderReflogSurface(surface);
38246
38779
  }
38247
38780
  if (state.activeView === 'bisect') {
38248
- return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
38781
+ return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
38249
38782
  }
38250
38783
  if (state.activeView === 'stash') {
38251
- return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38784
+ return renderStashSurface(surface);
38252
38785
  }
38253
38786
  if (state.activeView === 'worktrees') {
38254
- return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38787
+ return renderWorktreesSurface(surface);
38255
38788
  }
38256
38789
  if (state.activeView === 'submodules') {
38257
- return renderSubmodulesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38790
+ return renderSubmodulesSurface(surface);
38258
38791
  }
38259
38792
  if (state.activeView === 'pull-request') {
38260
- return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38793
+ return renderPullRequestSurface(surface);
38261
38794
  }
38262
38795
  if (state.activeView === 'pull-request-triage') {
38263
- return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38796
+ return renderPullRequestTriageSurface(surface);
38264
38797
  }
38265
38798
  if (state.activeView === 'issues') {
38266
- return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38799
+ return renderIssuesTriageSurface(surface);
38267
38800
  }
38268
38801
  if (state.activeView === 'conflicts') {
38269
- return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38802
+ return renderConflictsSurface(surface);
38270
38803
  }
38271
38804
  if (state.activeView === 'changelog') {
38272
- return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38805
+ return renderChangelogSurface(surface);
38273
38806
  }
38274
- return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
38807
+ return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
38275
38808
  }
38276
38809
 
38277
38810
  /**
@@ -39144,21 +39677,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
39144
39677
  const bodyVisualLines = bodyHasContent
39145
39678
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
39146
39679
  : ['<empty>'];
39147
- const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, bodyTextWidth);
39148
- const summaryFirst = `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${summaryWrapped[0] || ''}`;
39149
- const summaryRest = summaryWrapped.slice(1).map((line) => ` ${line}`);
39150
- const headerLines = [
39151
- statusLine,
39152
- '',
39153
- summaryFirst,
39154
- ...summaryRest,
39155
- `${compose.field === 'body' && compose.editing ? '>' : ' '} Body:`,
39156
- ...bodyVisualLines.map((line, index) => {
39157
- const isLast = index === bodyVisualLines.length - 1;
39158
- return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
39159
- }),
39160
- '',
39161
- ];
39680
+ const hasSummary = Boolean(compose.summary);
39681
+ const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
39682
+ const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
39683
+ // The generated subject is the thing the user is looking for — render
39684
+ // it bold + accent so it pops out of the inspector instead of blending
39685
+ // into the dim label/body text. The `Summary:` label stays dim.
39686
+ const summaryLabel = `${summaryMarker} Summary: `;
39687
+ const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
39688
+ const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
39689
+ const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
39162
39690
  const trailerLines = [
39163
39691
  ...(compose.message ? ['', compose.message] : []),
39164
39692
  ...(compose.details || []).map((line) => ` ${line}`),
@@ -39172,10 +39700,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
39172
39700
  flexDirection: 'column',
39173
39701
  width,
39174
39702
  paddingX: 1,
39175
- }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
39176
- key: `commit-header-${index}`,
39177
- dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
39178
- }, truncateCells(line, width - 4))),
39703
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
39704
+ // Summary: dim label + the subject value emphasized so it's easy to spot.
39705
+ h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
39706
+ bold: hasSummary,
39707
+ color: summaryColor,
39708
+ dimColor: !hasSummary,
39709
+ }, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
39710
+ key: `commit-summary-rest-${index}`,
39711
+ bold: true,
39712
+ color: summaryColor,
39713
+ }, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
39714
+ key: 'commit-body-label',
39715
+ dimColor: !(compose.field === 'body' && compose.editing),
39716
+ }, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
39717
+ const isLast = index === bodyVisualLines.length - 1;
39718
+ return h(Text, {
39719
+ key: `commit-body-${index}`,
39720
+ dimColor: true,
39721
+ }, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
39722
+ }), h(Text, { key: 'commit-spacer-2' }, ''),
39179
39723
  // Loading indicator + commit result/details stay inline with the body
39180
39724
  // (they describe what just happened to the fields above). The action
39181
39725
  // hint ("e edit | c commit | I AI draft") moves to the bottom of the
@@ -39266,41 +39810,11 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
39266
39810
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
39267
39811
  * of #890. No behavior change.
39268
39812
  */
39269
- /**
39270
- * Rail-mode inspector — shown on terminals < 100 columns when the
39271
- * detail panel does not hold focus. The full inspector (commit body,
39272
- * file list, actions) does not survive truncation to ~4 content cells
39273
- * so we collapse to a stack with the panel label and the selected
39274
- * commit's shortHash. Focus pops the panel back to its expanded
39275
- * widths via the layout, so this renderer is only reached at rest.
39276
- *
39277
- * Help / overlay states are still handled by their own renderers
39278
- * above; this short-circuit only kicks in for the regular "view the
39279
- * commit" cases.
39280
- */
39281
- function renderInspectorRail(h, components, state, detail, width, theme, focused) {
39282
- const { Box, Text } = components;
39283
- // Prefer the loaded detail's hash (canonical) but fall back to the
39284
- // selected list row's shortHash so the rail isn't blank on the
39285
- // first render before getCommitDetail resolves.
39286
- const selectedRow = getSelectedInkCommit(state);
39287
- const hashText = detail?.hash.slice(0, 4)
39288
- ?? selectedRow?.shortHash.slice(0, 4)
39289
- ?? '····';
39290
- return h(Box, {
39291
- borderColor: focusBorderColor(theme, focused),
39292
- borderStyle: theme.borderStyle,
39293
- flexDirection: 'column',
39294
- width,
39295
- paddingX: 1,
39296
- }, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
39297
- }
39298
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
39813
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
39299
39814
  const focused = state.focus === 'detail';
39300
39815
  // Overlays (help / palette / input / confirmation / chord) take
39301
- // precedence over rail because they always claim the panel's width
39302
- // via the help-overlay layout branch — and railing those would
39303
- // defeat their whole purpose (the user is reading them).
39816
+ // precedence over every per-view surface because they claim the
39817
+ // panel's full width via the help-overlay layout branch.
39304
39818
  if (state.showHelp) {
39305
39819
  return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
39306
39820
  }
@@ -39332,15 +39846,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39332
39846
  if (state.pendingKey && !state.splitPlan) {
39333
39847
  return renderChordOverlay(h, components, state, width, theme, focused);
39334
39848
  }
39335
- // Rail mode applies only after every overlay above has had its say
39336
- // — those would all be unreadable at 4 cells of content. The layout
39337
- // also clears `railed` whenever the inspector takes focus, so we
39338
- // can safely short-circuit the per-view dispatch here without
39339
- // worrying about hiding the panel from a user who's actively
39340
- // reading it.
39341
- if (railed) {
39342
- return renderInspectorRail(h, components, state, detail, width, theme, focused);
39343
- }
39344
39849
  // The synthetic "(+) new commit" row routes the inspector through the
39345
39850
  // worktree summary so the user sees what's staged / unstaged at a glance
39346
39851
  // — same surface as the compose view's right panel.
@@ -39390,6 +39895,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39390
39895
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
39391
39896
  }
39392
39897
 
39898
+ /**
39899
+ * Runtime React Context for the workstation (#1136).
39900
+ *
39901
+ * The render layer currently drills `state` / `dispatch` / `theme` /
39902
+ * `layout` / `context` through every `render*Surface` signature, so
39903
+ * adding a feature repeatedly means threading one more value through
39904
+ * `app → mainPanel → render<View>Surface`. This Context is the single
39905
+ * place those five values live; surfaces read what they need from it
39906
+ * instead of receiving 10–15 positional props.
39907
+ *
39908
+ * Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
39909
+ * module-level `React.createContext(...)`: the workstation never
39910
+ * statically imports React. `ink` + `react` are ESM-only and loaded via
39911
+ * dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
39912
+ * codebase compiles without bundling them. The Context object must be
39913
+ * built from that same runtime React instance — the one that renders
39914
+ * the tree and the one a consumer's `useContext` reads from have to be
39915
+ * identical. There is exactly one React instance per process, so we
39916
+ * lazily create the Context on first use and cache it; `LogInkApp`'s
39917
+ * provider and (in later PRs) the surface consumers all share the one
39918
+ * identity.
39919
+ */
39920
+ let cachedContext = null;
39921
+ /**
39922
+ * Lazily create (and thereafter return) the process-wide
39923
+ * `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
39924
+ * same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
39925
+ * tests use the statically-imported `react`.
39926
+ */
39927
+ function getLogInkRuntimeContext(React) {
39928
+ if (!cachedContext) {
39929
+ cachedContext = React.createContext(null);
39930
+ cachedContext.displayName = 'LogInkRuntimeContext';
39931
+ }
39932
+ return cachedContext;
39933
+ }
39934
+
39393
39935
  /**
39394
39936
  * Resolve + scaffold the coco config files the workstation can open in
39395
39937
  * `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
@@ -39873,6 +40415,10 @@ function LogInkApp(deps) {
39873
40415
  const loadingMoreCommitsRef = React.useRef(false);
39874
40416
  const loadMoreRequestRef = React.useRef(0);
39875
40417
  const mountedRef = React.useRef(true);
40418
+ // Last dropped stash {hash, message}, captured before `drop-stash` runs
40419
+ // so `undo-drop-stash` can re-store it. The dropped commit survives in
40420
+ // the object DB until gc, so the hash is enough to bring it back.
40421
+ const lastDroppedStashRef = React.useRef(null);
39876
40422
  // P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
39877
40423
  // a grace window of empty statusMessage and then on a steady cadence, so
39878
40424
  // the footer surfaces a different hint every interval until the user does
@@ -41116,11 +41662,15 @@ function LogInkApp(deps) {
41116
41662
  dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
41117
41663
  return;
41118
41664
  }
41665
+ // Humanize provider errors (rate limit / auth / context / network)
41666
+ // into a short actionable line; success-but-no-draft keeps its
41667
+ // message as-is.
41668
+ const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
41119
41669
  dispatch({
41120
41670
  type: 'commitCompose',
41121
- action: { type: 'setResult', message: result.message, details: result.details },
41671
+ action: { type: 'setResult', message: composeMessage, details: result.details },
41122
41672
  });
41123
- dispatch({ type: 'setStatus', value: result.message });
41673
+ dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
41124
41674
  }
41125
41675
  catch (error) {
41126
41676
  // Audit finding #3: defensive recovery for unexpected throws
@@ -42091,8 +42641,20 @@ function LogInkApp(deps) {
42091
42641
  const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42092
42642
  if (!stash)
42093
42643
  return { ok: false, message: 'No stash selected' };
42644
+ // Remember the dropped commit so `u` can undo it.
42645
+ if (stash.hash)
42646
+ lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
42094
42647
  return dropStash(git, stash);
42095
42648
  },
42649
+ 'undo-drop-stash': async () => {
42650
+ const dropped = lastDroppedStashRef.current;
42651
+ if (!dropped)
42652
+ return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
42653
+ const result = await restoreStash(git, dropped.hash, dropped.message);
42654
+ if (result.ok)
42655
+ lastDroppedStashRef.current = null;
42656
+ return result;
42657
+ },
42096
42658
  'apply-stash': async () => {
42097
42659
  const all = context.stashes?.stashes || [];
42098
42660
  const visible = state.filter
@@ -42103,6 +42665,16 @@ function LogInkApp(deps) {
42103
42665
  return { ok: false, message: 'No stash selected' };
42104
42666
  return applyStash(git, stash);
42105
42667
  },
42668
+ 'apply-stash-index': async () => {
42669
+ const all = context.stashes?.stashes || [];
42670
+ const visible = state.filter
42671
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
42672
+ : all;
42673
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42674
+ if (!stash)
42675
+ return { ok: false, message: 'No stash selected' };
42676
+ return applyStashKeepIndex(git, stash);
42677
+ },
42106
42678
  'pop-stash': async () => {
42107
42679
  const all = context.stashes?.stashes || [];
42108
42680
  const visible = state.filter
@@ -42113,6 +42685,26 @@ function LogInkApp(deps) {
42113
42685
  return { ok: false, message: 'No stash selected' };
42114
42686
  return popStash(git, stash);
42115
42687
  },
42688
+ 'rename-stash': async () => {
42689
+ const all = context.stashes?.stashes || [];
42690
+ const visible = state.filter
42691
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
42692
+ : all;
42693
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42694
+ if (!stash)
42695
+ return { ok: false, message: 'No stash selected' };
42696
+ return renameStash(git, stash, payload ?? '');
42697
+ },
42698
+ 'stash-branch': async () => {
42699
+ const all = context.stashes?.stashes || [];
42700
+ const visible = state.filter
42701
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
42702
+ : all;
42703
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42704
+ if (!stash)
42705
+ return { ok: false, message: 'No stash selected' };
42706
+ return stashBranch(git, stash, payload ?? '');
42707
+ },
42116
42708
  'bisect-good': async () => {
42117
42709
  if (!context.bisect?.active)
42118
42710
  return { ok: false, message: 'No bisect in progress' };
@@ -42523,11 +43115,12 @@ function LogInkApp(deps) {
42523
43115
  return deleteRemoteTag(git, tag.name);
42524
43116
  },
42525
43117
  'create-stash': async () => {
42526
- const message = payload?.trim();
42527
- if (!message)
42528
- return { ok: false, message: 'Stash message required' };
42529
- return createStash(git, message);
43118
+ // Empty is allowed — createStash turns it into a quick WIP stash
43119
+ // (git's own `WIP on <branch>` subject). Naming is optional.
43120
+ return createStash(git, payload ?? '');
42530
43121
  },
43122
+ 'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
43123
+ 'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
42531
43124
  // #783 — full PR action panel handlers. Each wraps the matching
42532
43125
  // pullRequestActions verb. Strategy / body arrives via `payload`
42533
43126
  // — input prompts validate before they reach here, but the
@@ -42775,6 +43368,8 @@ function LogInkApp(deps) {
42775
43368
  const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
42776
43369
  return stageAllFiles(git, files);
42777
43370
  },
43371
+ 'stage-all': async () => stageAll(git),
43372
+ 'stage-pathspec': async () => stagePathspec(git, payload || ''),
42778
43373
  };
42779
43374
  const handler = handlers[id];
42780
43375
  if (!handler) {
@@ -42805,6 +43400,16 @@ function LogInkApp(deps) {
42805
43400
  'checkout-branch',
42806
43401
  'continue-operation',
42807
43402
  'pull-current-branch',
43403
+ // Fetch / pull / push bring in new commits and move
43404
+ // remote-tracking refs (origin/main, ahead/behind) — refresh the
43405
+ // graph so they appear instead of staying pinned to the pre-sync
43406
+ // state. (A successful push advances the local origin/<branch>
43407
+ // ref, so the chip should hop to the pushed commit.)
43408
+ 'fetch-remotes',
43409
+ 'fetch-selected-branch',
43410
+ 'pull-selected-branch',
43411
+ 'push-current-branch',
43412
+ 'push-selected-branch',
42808
43413
  'cherry-pick-commit',
42809
43414
  'revert-commit',
42810
43415
  'reset-hard-to-commit',
@@ -42859,6 +43464,11 @@ function LogInkApp(deps) {
42859
43464
  if (result?.ok && id === 'add-to-gitignore') {
42860
43465
  await refreshWorktreeContext();
42861
43466
  }
43467
+ // Stage-all / stage-pathspec change staged/unstaged counts — refresh
43468
+ // the worktree so the status list + compose summary reflect it.
43469
+ if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
43470
+ await refreshWorktreeContext();
43471
+ }
42862
43472
  if (result?.ok && id === 'drop-stash') {
42863
43473
  // Explicit worktree refresh in case the dropped stash carried
42864
43474
  // untracked-file state that's now collected.
@@ -43442,6 +44052,11 @@ function LogInkApp(deps) {
43442
44052
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
43443
44053
  : undefined;
43444
44054
  getLogInkInputEvents(state, inputValue, key, {
44055
+ // Narrow terminals show one pane at a time (#1135) — gates the `v`
44056
+ // peek key. Derived the same way the layout does, since `layout`
44057
+ // is computed later in the render path (not in this callback).
44058
+ singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
44059
+ LAYOUT_SINGLE_PANE_BELOW,
43445
44060
  detailFileCount: detail?.files.length,
43446
44061
  previewLineCount: diffPreviewLineCount,
43447
44062
  worktreeDiffLineCount: worktreeDiff?.lines.length,
@@ -43566,7 +44181,14 @@ function LogInkApp(deps) {
43566
44181
  exit();
43567
44182
  }
43568
44183
  else if (event.type === 'refreshContext') {
44184
+ // The user-initiated refresh (`r`) refreshes BOTH the metadata
44185
+ // context (branches/tags/worktree) AND the commit rows. Without
44186
+ // the row re-fetch the history graph stays pinned to whatever
44187
+ // commits existed at boot — new commits (made in another
44188
+ // terminal, or remote commits brought in by a fetch) never
44189
+ // appear until relaunch, which reads as "the history is stuck."
43569
44190
  void refreshContext();
44191
+ void refreshHistoryRows();
43570
44192
  }
43571
44193
  else if (event.type === 'toggleSelectedFileStage') {
43572
44194
  void toggleSelectedFileStage();
@@ -43665,6 +44287,24 @@ function LogInkApp(deps) {
43665
44287
  }
43666
44288
  });
43667
44289
  });
44290
+ // In single-pane mode (narrow terminals) only one pane renders, so an
44291
+ // active overlay must pull its own pane into view rather than stay
44292
+ // hidden behind whatever pane focus points at. The split-plan overlay
44293
+ // lives in the main panel; every other overlay (help / palette / theme
44294
+ // / gitignore / input prompt / confirmation / chord) renders in the
44295
+ // inspector. Ignored above the single-pane breakpoint (all panes show).
44296
+ const forcedPane = state.splitPlan
44297
+ ? 'main'
44298
+ : state.showHelp ||
44299
+ state.showCommandPalette ||
44300
+ state.showThemePicker ||
44301
+ state.gitignorePicker ||
44302
+ state.inputPrompt ||
44303
+ state.pendingConfirmationId ||
44304
+ state.pendingMutationConfirmation ||
44305
+ state.pendingKey
44306
+ ? 'inspector'
44307
+ : undefined;
43668
44308
  // Layout depends on focus (sidebar grows when focused), so it's
43669
44309
  // computed here — after state is in scope but before the render path.
43670
44310
  const layout = getLogInkLayout({
@@ -43673,7 +44313,22 @@ function LogInkApp(deps) {
43673
44313
  sidebarFocused: state.focus === 'sidebar',
43674
44314
  inspectorFocused: state.focus === 'detail',
43675
44315
  helpOverlayActive: state.showHelp,
44316
+ forcedPane,
43676
44317
  });
44318
+ // Runtime Context provider (#1136). Bundles the five most-drilled
44319
+ // values so surfaces can read them from context instead of receiving
44320
+ // them as positional props. No consumers yet — this PR only installs
44321
+ // the provider at the root; the surface families migrate in later PRs.
44322
+ // A Context.Provider renders its children transparently (no host
44323
+ // output), so wrapping the tree is behavior-preserving.
44324
+ const RuntimeContext = getLogInkRuntimeContext(React);
44325
+ const runtimeContextValue = {
44326
+ state,
44327
+ dispatch,
44328
+ theme,
44329
+ layout,
44330
+ context,
44331
+ };
43677
44332
  if (layout.tooSmall) {
43678
44333
  return h(Box, {
43679
44334
  flexDirection: 'column',
@@ -43687,7 +44342,35 @@ function LogInkApp(deps) {
43687
44342
  if (showOnboarding) {
43688
44343
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
43689
44344
  }
43690
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
44345
+ // Panel renderers are thunks so single-pane mode can build only the
44346
+ // visible pane — the main-panel render in particular is expensive, so
44347
+ // we don't want to invoke the two hidden ones just to drop them.
44348
+ const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
44349
+ const mainSurface = {
44350
+ h,
44351
+ components: { Box, Text },
44352
+ state,
44353
+ context,
44354
+ contextStatus,
44355
+ bodyRows: layout.bodyRows,
44356
+ width: layout.mainPanelWidth,
44357
+ theme,
44358
+ };
44359
+ const mainPanel = () => renderMainPanel(mainSurface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans);
44360
+ const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
44361
+ // Single-pane mode (narrow terminals): exactly one full-width pane,
44362
+ // chosen by `layout.visiblePane`; Tab cycles which one. Above the
44363
+ // breakpoint all three tile side by side as before.
44364
+ const bodyPanels = layout.singlePane
44365
+ ? [
44366
+ layout.visiblePane === 'sidebar'
44367
+ ? sidebarPanel()
44368
+ : layout.visiblePane === 'inspector'
44369
+ ? detailPanel()
44370
+ : mainPanel(),
44371
+ ]
44372
+ : [sidebarPanel(), mainPanel(), detailPanel()];
44373
+ return h(RuntimeContext.Provider, { value: runtimeContextValue }, h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, ...bodyPanels), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame, layout.singlePane)));
43691
44374
  }
43692
44375
 
43693
44376
  /**
@@ -45807,6 +46490,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
45807
46490
  return { authenticated: true, counts };
45808
46491
  }
45809
46492
 
46493
+ /**
46494
+ * Clone a remote repository into a local path — the runtime side of the
46495
+ * workspace surface's `c` (clone) flow.
46496
+ *
46497
+ * `deriveRepoName` is pure (and tested) so the UI can pre-fill the
46498
+ * destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
46499
+ * does the filesystem-touching work and reports a friendly result.
46500
+ */
46501
+ /**
46502
+ * Infer the repository folder name from a clone URL or SSH spec:
46503
+ * git@github.com:gfargo/coco.git → coco
46504
+ * https://github.com/gfargo/coco → coco
46505
+ * https://example.com/a/b/c.git/ → c
46506
+ * Falls back to `repo` when nothing usable can be parsed.
46507
+ */
46508
+ function deriveRepoName(url) {
46509
+ const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
46510
+ if (!trimmed)
46511
+ return 'repo';
46512
+ // Split on both `/` and `:` so `host:owner/name` SSH specs work.
46513
+ const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
46514
+ return segment || 'repo';
46515
+ }
46516
+ /**
46517
+ * Clone `url` into `targetPath`. Refuses to clobber an existing path so
46518
+ * a typo never overwrites a directory. Network / auth failures surface
46519
+ * git's own message (trimmed to one line).
46520
+ */
46521
+ async function cloneRepo(url, targetPath) {
46522
+ const remote = url.trim();
46523
+ const dest = targetPath.trim();
46524
+ if (!remote)
46525
+ return { ok: false, message: 'Enter a remote URL to clone.' };
46526
+ if (!dest)
46527
+ return { ok: false, message: 'Enter a destination path.' };
46528
+ if (fs.existsSync(dest)) {
46529
+ return { ok: false, message: `${dest} already exists — choose another path.` };
46530
+ }
46531
+ try {
46532
+ await simpleGit().clone(remote, dest);
46533
+ return { ok: true, message: `Cloned into ${dest}` };
46534
+ }
46535
+ catch (error) {
46536
+ const raw = error instanceof Error ? error.message : String(error);
46537
+ return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
46538
+ }
46539
+ }
46540
+
45810
46541
  function resolveStoreDir(subdir) {
45811
46542
  const xdg = process.env.XDG_CACHE_HOME;
45812
46543
  const root = xdg && xdg.trim().length > 0 ? xdg : path$1.join(os$1.homedir(), '.cache');
@@ -46049,6 +46780,7 @@ function createWorkspaceState(init) {
46049
46780
  showThemePicker: false,
46050
46781
  themePickerFilter: '',
46051
46782
  themePickerIndex: 0,
46783
+ helpScrollOffset: 0,
46052
46784
  knownRepoPaths: init.knownRepoPaths ?? [],
46053
46785
  pullRequestFetching: [],
46054
46786
  };
@@ -46214,10 +46946,17 @@ function applyWorkspaceAction(state, action) {
46214
46946
  return { ...state, status: action.status };
46215
46947
  }
46216
46948
  case 'toggle-help': {
46217
- return { ...state, showHelp: !state.showHelp, showOnboarding: false };
46949
+ // Always reopen at the top picking up the last scroll position
46950
+ // is more surprising than predictable for a reference overlay.
46951
+ return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
46218
46952
  }
46219
46953
  case 'close-help': {
46220
- return { ...state, showHelp: false };
46954
+ return { ...state, showHelp: false, helpScrollOffset: 0 };
46955
+ }
46956
+ case 'scroll-help': {
46957
+ // Floor-clamp at 0 only; the renderer ceiling-clamps against the
46958
+ // real content height so `j` past the end sticks at the last row.
46959
+ return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
46221
46960
  }
46222
46961
  case 'toggle-theme-picker': {
46223
46962
  return {
@@ -46547,6 +47286,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
46547
47286
  const all = buildWorkspaceListRows(state, {
46548
47287
  width: options.width,
46549
47288
  spinnerTick: options.spinnerTick,
47289
+ now: options.now,
46550
47290
  });
46551
47291
  const visibleCount = Math.max(1, options.rows);
46552
47292
  if (all.length <= visibleCount) {
@@ -46670,10 +47410,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
46670
47410
  // The contextual slot drops bindings users can find via the help
46671
47411
  // overlay (arrow keys, tab); the global slot is the safety net so
46672
47412
  // `? help` and `q quit` never disappear.
46673
- const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
47413
+ const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
46674
47414
  const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
46675
47415
  const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
46676
47416
  const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
47417
+ const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
46677
47418
  const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
46678
47419
  const GLOBAL_HINTS = ['? help', 'q quit'];
46679
47420
  function contextualHintsFor(focus) {
@@ -46684,6 +47425,8 @@ function contextualHintsFor(focus) {
46684
47425
  return FILTER_CONTEXTUAL;
46685
47426
  case 'add-repo':
46686
47427
  return ADD_REPO_CONTEXTUAL;
47428
+ case 'clone-repo':
47429
+ return CLONE_REPO_CONTEXTUAL;
46687
47430
  case 'confirm-delete':
46688
47431
  return CONFIRM_DELETE_CONTEXTUAL;
46689
47432
  case 'list':
@@ -46698,6 +47441,7 @@ function buildWorkspaceFooter(state) {
46698
47441
  // is open and showing them would be misleading.
46699
47442
  const isModal = state.focus === 'filter' ||
46700
47443
  state.focus === 'add-repo' ||
47444
+ state.focus === 'clone-repo' ||
46701
47445
  state.focus === 'confirm-delete';
46702
47446
  const global = isModal ? [] : GLOBAL_HINTS;
46703
47447
  const allHints = [...contextual, ...global];
@@ -46759,6 +47503,7 @@ function buildWorkspaceHelpSections() {
46759
47503
  { glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
46760
47504
  { glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
46761
47505
  { glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
47506
+ { glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
46762
47507
  { glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
46763
47508
  ],
46764
47509
  },
@@ -46776,7 +47521,7 @@ function buildWorkspaceOnboarding(state) {
46776
47521
  : undefined,
46777
47522
  populatedHint: empty
46778
47523
  ? undefined
46779
- : 'Press `enter` to open a repo · `?` for the full keymap · `a` to add a repo by path.',
47524
+ : 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
46780
47525
  };
46781
47526
  }
46782
47527
 
@@ -47072,6 +47817,7 @@ function renderListBody(deps, width, height) {
47072
47817
  width,
47073
47818
  rows: listRows,
47074
47819
  spinnerTick: deps.spinnerTick,
47820
+ now: deps.now,
47075
47821
  });
47076
47822
  const visibleRepos = selectVisibleRepos(state);
47077
47823
  const filterChip = state.filter
@@ -47107,7 +47853,7 @@ function renderListBody(deps, width, height) {
47107
47853
  function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
47108
47854
  const { React, ink, theme } = deps;
47109
47855
  const { Box, Text } = ink;
47110
- return React.createElement(Box, { key, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
47856
+ return React.createElement(Box, { key, flexShrink: 0, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
47111
47857
  }
47112
47858
  function renderHelpOverlay(deps) {
47113
47859
  if (!deps.state.showHelp) {
@@ -47120,34 +47866,68 @@ function renderHelpOverlay(deps) {
47120
47866
  // Columns: glyph cell (4 cells) · keys (padded to longest) · description.
47121
47867
  const glyphWidth = 4;
47122
47868
  const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
47123
- const children = [];
47124
- // Title bar accent-tinged, matches the chip-style header on the
47125
- // main surface so the help reads as the same app, just a different
47126
- // panel.
47127
- children.push(React.createElement(Box, { key: 'title', flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Box, { key: 'title-left', flexDirection: 'row' }, React.createElement(Text, { bold: true, color: theme.noColor ? undefined : theme.colors.accent }, ' ? coco workspace'), React.createElement(Text, { dimColor: true }, ' keymap · '), React.createElement(Text, { dimColor: true }, `${allRows.length} bindings`)), React.createElement(Text, { dimColor: true }, 'esc / ? to close ')));
47128
- children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
47129
- // Sections each gets a title in accent, optional subtitle dim,
47130
- // then its rows, then a blank line.
47869
+ // Body lines — every scrollable row below the pinned title. Built as
47870
+ // a flat list (section title optional subtitle rows → inter-section
47871
+ // spacer) so we can window it against the available height. Each entry
47872
+ // is `flexShrink: 0` so Ink never crushes rows on top of each other
47873
+ // when the keymap is taller than the panel (which used to collapse the
47874
+ // title and the first category onto the same line).
47875
+ const body = [];
47131
47876
  sections.forEach((section, sIndex) => {
47132
- children.push(React.createElement(Text, {
47877
+ body.push(React.createElement(Text, {
47133
47878
  key: `section-${sIndex}-title`,
47134
47879
  bold: true,
47135
47880
  color: theme.noColor ? undefined : theme.colors.muted,
47136
47881
  }, section.title.toUpperCase()));
47137
47882
  if (section.subtitle) {
47138
- children.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
47883
+ body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
47139
47884
  }
47140
47885
  section.rows.forEach((row, rIndex) => {
47141
- children.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
47886
+ body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
47142
47887
  });
47143
47888
  if (sIndex < sections.length - 1) {
47144
- children.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
47889
+ body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
47145
47890
  }
47146
47891
  });
47892
+ // Vertical budget: the overlay shares the column with the header
47893
+ // (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
47894
+ // (2), the pinned title (1) and the title/body separator (1). Whatever
47895
+ // is left is the window we slide the body through.
47896
+ const HEADER_ROWS = 3;
47897
+ const overlayChromeRows = 4;
47898
+ const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
47899
+ // Ceiling-clamp the offset here (the reducer only floors at 0) so
47900
+ // scrolling past the end sticks at the last row instead of revealing
47901
+ // blank space.
47902
+ const maxOffset = Math.max(0, body.length - visibleRows);
47903
+ const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
47904
+ const children = [];
47905
+ // Title bar — accent-tinged, matches the chip-style header on the
47906
+ // main surface so the help reads as the same app, just a different
47907
+ // panel. Pinned above the scrolling body.
47908
+ children.push(React.createElement(Box, { key: 'title', flexShrink: 0, flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Box, { key: 'title-left', flexDirection: 'row' }, React.createElement(Text, { bold: true, color: theme.noColor ? undefined : theme.colors.accent }, ' ? coco workspace'), React.createElement(Text, { dimColor: true }, ' keymap · '), React.createElement(Text, { dimColor: true }, `${allRows.length} bindings`)), React.createElement(Text, { dimColor: true }, 'esc / ? to close ')));
47909
+ children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
47910
+ // "more above" / "more below" hints each consume a window row so they
47911
+ // don't push body content off-screen. Mirrors the `coco ui` overlay.
47912
+ let windowSize = visibleRows;
47913
+ const hasMoreAbove = offset > 0;
47914
+ if (hasMoreAbove) {
47915
+ windowSize -= 1;
47916
+ children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
47917
+ }
47918
+ const hasMoreBelow = offset + windowSize < body.length;
47919
+ if (hasMoreBelow) {
47920
+ windowSize -= 1;
47921
+ }
47922
+ children.push(...body.slice(offset, offset + windowSize));
47923
+ if (hasMoreBelow) {
47924
+ children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
47925
+ }
47147
47926
  return React.createElement(Box, {
47148
47927
  borderColor: focusBorderColor(theme, true),
47149
47928
  borderStyle: theme.borderStyle,
47150
47929
  flexDirection: 'column',
47930
+ flexShrink: 0,
47151
47931
  paddingX: 1,
47152
47932
  }, ...children);
47153
47933
  }
@@ -47198,6 +47978,29 @@ function renderAddRepoPrompt(deps) {
47198
47978
  ? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
47199
47979
  : null);
47200
47980
  }
47981
+ function renderCloneRepoPrompt(deps) {
47982
+ if (deps.state.focus !== 'clone-repo') {
47983
+ return null;
47984
+ }
47985
+ const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
47986
+ const { Box, Text } = ink;
47987
+ const urlActive = cloneField === 'url' && !cloning;
47988
+ const targetActive = cloneField === 'target' && !cloning;
47989
+ const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
47990
+ const hint = cloning
47991
+ ? 'Cloning… this can take a moment for large repos.'
47992
+ : cloneField === 'url'
47993
+ ? 'Paste a remote URL (https or git@…), then enter for the destination.'
47994
+ : 'Edit the destination · tab to complete · enter to clone · esc to cancel';
47995
+ return React.createElement(Box, {
47996
+ borderColor: focusBorderColor(theme, true),
47997
+ borderStyle: theme.borderStyle,
47998
+ flexDirection: 'column',
47999
+ paddingX: 1,
48000
+ }, React.createElement(Text, { bold: true }, 'Clone a repository'), React.createElement(Text, { color: urlActive ? undefined : toneColor('dim', theme) }, ` URL: ${cloneUrl}${urlActive ? '_' : ''}`), React.createElement(Text, { color: targetActive ? undefined : toneColor('dim', theme) }, ` Into: ${cloneTarget}${targetActive ? '_' : ''}`), React.createElement(Text, { dimColor: true }, hint), completionLine && targetActive
48001
+ ? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
48002
+ : null);
48003
+ }
47201
48004
  const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
47202
48005
  function renderFooter(deps) {
47203
48006
  const { React, ink, state, theme } = deps;
@@ -47239,8 +48042,10 @@ function computeBodyHeight(deps) {
47239
48042
  const FOOTER_ROWS = FOOTER_HEIGHT;
47240
48043
  const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
47241
48044
  const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
48045
+ // Clone modal is one row taller (URL + Into + hint + completion).
48046
+ const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
47242
48047
  const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
47243
- const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
48048
+ const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
47244
48049
  return Math.max(8, deps.rows - reserved);
47245
48050
  }
47246
48051
  function renderWorkspaceApp(deps) {
@@ -47262,7 +48067,7 @@ function renderWorkspaceApp(deps) {
47262
48067
  return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderThemePickerOverlay(React.createElement, { Box: ink.Box, Text: ink.Text }, deps.state.themePickerFilter, deps.state.themePickerIndex, bodyWidth, deps.theme, true), renderFooter(deps));
47263
48068
  }
47264
48069
  const bodyHeight = computeBodyHeight(deps);
47265
- return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
48070
+ return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderCloneRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
47266
48071
  }
47267
48072
 
47268
48073
  /**
@@ -47299,6 +48104,21 @@ function resolveWorkspaceInput(input, key, state) {
47299
48104
  if (key.escape || input === '?' || input === 'q') {
47300
48105
  return { kind: 'action', action: { type: 'close-help' } };
47301
48106
  }
48107
+ // The keymap is taller than the panel on short terminals — let
48108
+ // j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
48109
+ // `coco ui` help overlay.
48110
+ if (key.downArrow || input === 'j') {
48111
+ return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
48112
+ }
48113
+ if (key.upArrow || input === 'k') {
48114
+ return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
48115
+ }
48116
+ if (key.ctrl && input === 'd') {
48117
+ return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
48118
+ }
48119
+ if (key.ctrl && input === 'u') {
48120
+ return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
48121
+ }
47302
48122
  return { kind: 'noop' };
47303
48123
  }
47304
48124
  // Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
@@ -47349,6 +48169,14 @@ function resolveWorkspaceInput(input, key, state) {
47349
48169
  // can drive the path-completion prompt.
47350
48170
  return { kind: 'noop' };
47351
48171
  }
48172
+ if (state.focus === 'clone-repo') {
48173
+ if (key.escape) {
48174
+ return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
48175
+ }
48176
+ // Enter/Tab/printable keys drive the URL + destination prompt in the
48177
+ // runtime (it owns the two-field state + path completion).
48178
+ return { kind: 'noop' };
48179
+ }
47352
48180
  // Confirm-delete is modal: only `y` confirms, anything else cancels.
47353
48181
  if (state.focus === 'confirm-delete') {
47354
48182
  if (input === 'y' || input === 'Y') {
@@ -47443,6 +48271,9 @@ function resolveWorkspaceInput(input, key, state) {
47443
48271
  if (input === 'a') {
47444
48272
  return { kind: 'add-repo' };
47445
48273
  }
48274
+ if (input === 'c') {
48275
+ return { kind: 'clone-repo' };
48276
+ }
47446
48277
  if (input === 'd') {
47447
48278
  return { kind: 'request-delete' };
47448
48279
  }
@@ -47997,6 +48828,19 @@ function WorkspaceInkApp(props) {
47997
48828
  const [filterDraft, setFilterDraft] = React.useState('');
47998
48829
  const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
47999
48830
  const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
48831
+ // Clone-repo modal (`c`). Two fields: the remote URL and the
48832
+ // destination path. `cloneField` tracks which is active; `cloneTarget`
48833
+ // auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
48834
+ // (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
48835
+ // `git clone` runs. The boot cwd is captured once at mount so it stays
48836
+ // the directory the workspace launched in even after drill-in.
48837
+ const bootCwdRef = React.useRef(process.cwd());
48838
+ const [cloneUrl, setCloneUrl] = React.useState('');
48839
+ const [cloneTarget, setCloneTarget] = React.useState('');
48840
+ const [cloneField, setCloneField] = React.useState('url');
48841
+ const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
48842
+ const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
48843
+ const [cloning, setCloning] = React.useState(false);
48000
48844
  // Tick counter for the per-row PR-fetch spinner. Bumped on a
48001
48845
  // setInterval that only runs while at least one row is mid-fetch
48002
48846
  // (see effect below) so idle workspaces don't burn CPU on animation
@@ -48046,6 +48890,18 @@ function WorkspaceInkApp(props) {
48046
48890
  addRepoDraftRef.current = addRepoDraft;
48047
48891
  const addRepoCompletionRef = React.useRef(addRepoCompletion);
48048
48892
  addRepoCompletionRef.current = addRepoCompletion;
48893
+ const cloneUrlRef = React.useRef(cloneUrl);
48894
+ cloneUrlRef.current = cloneUrl;
48895
+ const cloneTargetRef = React.useRef(cloneTarget);
48896
+ cloneTargetRef.current = cloneTarget;
48897
+ const cloneFieldRef = React.useRef(cloneField);
48898
+ cloneFieldRef.current = cloneField;
48899
+ const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
48900
+ cloneTargetEditedRef.current = cloneTargetEdited;
48901
+ const cloneCompletionRef = React.useRef(cloneCompletion);
48902
+ cloneCompletionRef.current = cloneCompletion;
48903
+ const cloningRef = React.useRef(cloning);
48904
+ cloningRef.current = cloning;
48049
48905
  // Background discovery + PR-count refresh on mount.
48050
48906
  React.useEffect(() => {
48051
48907
  let cancelled = false;
@@ -48278,6 +49134,60 @@ function WorkspaceInkApp(props) {
48278
49134
  });
48279
49135
  }
48280
49136
  }, [addRepoDraft, dispatch, props]);
49137
+ // Default destination for a clone URL: `<bootCwd>/<repo-name>`.
49138
+ const cloneTargetFor = React.useCallback((url) => {
49139
+ return path$1.join(bootCwdRef.current, deriveRepoName(url));
49140
+ }, []);
49141
+ const openClone = React.useCallback(() => {
49142
+ setCloneUrl('');
49143
+ setCloneTarget('');
49144
+ setCloneField('url');
49145
+ setCloneTargetEdited(false);
49146
+ setCloneCompletion(completePath(`${bootCwdRef.current}/`));
49147
+ dispatch({ type: 'set-focus', focus: 'clone-repo' });
49148
+ }, [dispatch]);
49149
+ const commitClone = React.useCallback(async () => {
49150
+ const url = cloneUrlRef.current.trim();
49151
+ const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
49152
+ if (!url) {
49153
+ dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
49154
+ return;
49155
+ }
49156
+ if (!target) {
49157
+ dispatch({ type: 'set-status', status: 'Enter a destination path.' });
49158
+ return;
49159
+ }
49160
+ setCloning(true);
49161
+ dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
49162
+ const result = await cloneRepo(url, target);
49163
+ if (unmountedRef.current)
49164
+ return;
49165
+ setCloning(false);
49166
+ if (!result.ok) {
49167
+ // Keep the modal open so the user can fix the URL / path and retry.
49168
+ dispatch({ type: 'set-status', status: result.message });
49169
+ return;
49170
+ }
49171
+ const updated = appendKnownRepo(target);
49172
+ dispatch({ type: 'replace-known-repos', paths: updated });
49173
+ dispatch({ type: 'set-focus', focus: 'list' });
49174
+ dispatch({ type: 'set-status', status: result.message });
49175
+ dispatch({ type: 'set-loading', loading: true });
49176
+ try {
49177
+ const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
49178
+ const overview = await props.loadOverview(props.roots, merged);
49179
+ writeCachedWorkspace(props.roots, overview);
49180
+ dispatch({ type: 'replace-overview', overview });
49181
+ dispatch({ type: 'anchor-cursor-by-path', path: target });
49182
+ }
49183
+ catch (err) {
49184
+ dispatch({ type: 'set-loading', loading: false });
49185
+ dispatch({
49186
+ type: 'set-status',
49187
+ status: err instanceof Error ? err.message : 'Refresh failed.',
49188
+ });
49189
+ }
49190
+ }, [dispatch, props]);
48281
49191
  // Callback refs so the stable input handler can reach the latest
48282
49192
  // closure without taking them in deps.
48283
49193
  const commitAddRepoRef = React.useRef(commitAddRepo);
@@ -48290,6 +49200,10 @@ function WorkspaceInkApp(props) {
48290
49200
  refreshRowRef.current = refreshRow;
48291
49201
  const openAddRepoRef = React.useRef(openAddRepo);
48292
49202
  openAddRepoRef.current = openAddRepo;
49203
+ const openCloneRef = React.useRef(openClone);
49204
+ openCloneRef.current = openClone;
49205
+ const commitCloneRef = React.useRef(commitClone);
49206
+ commitCloneRef.current = commitClone;
48293
49207
  const requestDeleteRef = React.useRef(requestDelete);
48294
49208
  requestDeleteRef.current = requestDelete;
48295
49209
  const confirmDeleteRef = React.useRef(confirmDelete);
@@ -48372,6 +49286,73 @@ function WorkspaceInkApp(props) {
48372
49286
  }
48373
49287
  return;
48374
49288
  }
49289
+ if (state.focus === 'clone-repo') {
49290
+ // While the clone is running, swallow everything except Esc
49291
+ // (which is a no-op here — the clone is already in flight).
49292
+ if (cloningRef.current)
49293
+ return;
49294
+ if (key.escape) {
49295
+ dispatch({ type: 'set-focus', focus: 'list' });
49296
+ return;
49297
+ }
49298
+ const field = cloneFieldRef.current;
49299
+ const url = cloneUrlRef.current;
49300
+ const target = cloneTargetRef.current;
49301
+ const targetEdited = cloneTargetEditedRef.current;
49302
+ if (key.return) {
49303
+ if (field === 'url') {
49304
+ if (!url.trim()) {
49305
+ dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
49306
+ return;
49307
+ }
49308
+ // Advance to the (pre-filled, editable) destination field.
49309
+ const derived = targetEdited ? target : cloneTargetFor(url);
49310
+ setCloneTarget(derived);
49311
+ setCloneCompletion(completePath(derived));
49312
+ setCloneField('target');
49313
+ return;
49314
+ }
49315
+ void commitCloneRef.current();
49316
+ return;
49317
+ }
49318
+ if (key.tab && field === 'target') {
49319
+ const next = applyTabCompletion(target, cloneCompletionRef.current);
49320
+ setCloneTarget(next);
49321
+ setCloneTargetEdited(true);
49322
+ setCloneCompletion(completePath(next));
49323
+ return;
49324
+ }
49325
+ if (key.backspace || key.delete) {
49326
+ if (field === 'url') {
49327
+ const next = url.slice(0, -1);
49328
+ setCloneUrl(next);
49329
+ if (!targetEdited)
49330
+ setCloneTarget(next ? cloneTargetFor(next) : '');
49331
+ }
49332
+ else {
49333
+ const next = target.slice(0, -1);
49334
+ setCloneTarget(next);
49335
+ setCloneTargetEdited(true);
49336
+ setCloneCompletion(completePath(next || '~/'));
49337
+ }
49338
+ return;
49339
+ }
49340
+ if (rawInput && !key.ctrl && !key.meta) {
49341
+ if (field === 'url') {
49342
+ const next = url + rawInput;
49343
+ setCloneUrl(next);
49344
+ if (!targetEdited)
49345
+ setCloneTarget(cloneTargetFor(next));
49346
+ }
49347
+ else {
49348
+ const next = target + rawInput;
49349
+ setCloneTarget(next);
49350
+ setCloneTargetEdited(true);
49351
+ setCloneCompletion(completePath(next));
49352
+ }
49353
+ }
49354
+ return;
49355
+ }
48375
49356
  // Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
48376
49357
  // Handled here (rather than in the pure resolver) because the
48377
49358
  // resolver doesn't have a notion of "raw key with ctrl flag" for
@@ -48412,6 +49393,9 @@ function WorkspaceInkApp(props) {
48412
49393
  case 'add-repo':
48413
49394
  openAddRepoRef.current();
48414
49395
  break;
49396
+ case 'clone-repo':
49397
+ openCloneRef.current();
49398
+ break;
48415
49399
  case 'request-delete':
48416
49400
  requestDeleteRef.current();
48417
49401
  break;
@@ -48461,6 +49445,11 @@ function WorkspaceInkApp(props) {
48461
49445
  filterDraft,
48462
49446
  addRepoDraft,
48463
49447
  addRepoCompletion,
49448
+ cloneUrl,
49449
+ cloneTarget,
49450
+ cloneField,
49451
+ cloneCompletion,
49452
+ cloning,
48464
49453
  columns,
48465
49454
  rows,
48466
49455
  spinnerTick,