git-coco 0.59.1 → 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.
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.59.1";
81
+ const BUILD_VERSION = "0.60.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -18920,9 +18920,17 @@ async function getStashOverview(git) {
18920
18920
  // %gd — stash reflog selector (stash@{N})
18921
18921
  // %H — stash commit hash
18922
18922
  // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18923
- // %ci — committer date, ISO format
18923
+ // %cI — committer date, strict ISO 8601
18924
18924
  // %gs — reflog subject ("WIP on main: <subject>")
18925
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
18925
+ //
18926
+ // NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
18927
+ // `%gd` selector from the index form (`stash@{0}`) into a timestamp
18928
+ // (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
18929
+ // row width, and — critically — breaks `renameStash`, which parses the
18930
+ // `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
18931
+ // independent of `--date`, so we get both a clean index ref and a
18932
+ // parseable date.
18933
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
18926
18934
  return {
18927
18935
  stashes: await Promise.all(stashes.map(async (stash) => ({
18928
18936
  ...stash,
@@ -20591,7 +20599,7 @@ function applyCommitComposeAction(state, action) {
20591
20599
  loading: false,
20592
20600
  streamingPreview: undefined,
20593
20601
  pendingAiDraft: action.value,
20594
- message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20602
+ message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
20595
20603
  details: undefined,
20596
20604
  };
20597
20605
  }
@@ -20640,7 +20648,7 @@ function applyCommitComposeAction(state, action) {
20640
20648
  loading: false,
20641
20649
  streamingPreview: undefined,
20642
20650
  pendingAiDraft: action.value,
20643
- message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20651
+ message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
20644
20652
  details: undefined,
20645
20653
  };
20646
20654
  case 'acceptPendingAiDraft':
@@ -22196,6 +22204,25 @@ function getLogInkWorkflowActions() {
22196
22204
  kind: 'destructive',
22197
22205
  requiresConfirmation: true,
22198
22206
  },
22207
+ {
22208
+ // Palette-only create variants (empty `key`): no global hotkey to
22209
+ // collide with `S` / `gZ`, reachable from `:`. Both stash a quick
22210
+ // WIP entry with the requested scope.
22211
+ id: 'stash-staged',
22212
+ key: '',
22213
+ label: 'Stash staged only',
22214
+ description: 'Stash just the staged (index) changes — `git stash push --staged`.',
22215
+ kind: 'normal',
22216
+ requiresConfirmation: false,
22217
+ },
22218
+ {
22219
+ id: 'stash-keep-index',
22220
+ key: '',
22221
+ label: 'Stash keeping index',
22222
+ description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
22223
+ kind: 'normal',
22224
+ requiresConfirmation: false,
22225
+ },
22199
22226
  {
22200
22227
  id: 'remove-worktree',
22201
22228
  key: 'W',
@@ -22707,6 +22734,13 @@ const LOG_INK_KEY_BINDINGS = [
22707
22734
  description: 'Push the stash view (gz; gs is reserved for status).',
22708
22735
  contexts: ['normal'],
22709
22736
  },
22737
+ {
22738
+ id: 'createStash',
22739
+ keys: ['gZ'],
22740
+ label: 'stash changes',
22741
+ 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.',
22742
+ contexts: ['normal'],
22743
+ },
22710
22744
  {
22711
22745
  id: 'navigateWorktrees',
22712
22746
  keys: ['gw'],
@@ -22979,6 +23013,20 @@ const LOG_INK_KEY_BINDINGS = [
22979
23013
  description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
22980
23014
  contexts: ['status'],
22981
23015
  },
23016
+ {
23017
+ id: 'stageAll',
23018
+ keys: ['A'],
23019
+ label: 'stage all',
23020
+ description: 'Stage every change in the worktree (git add -A).',
23021
+ contexts: ['status', 'compose'],
23022
+ },
23023
+ {
23024
+ id: 'stagePathspec',
23025
+ keys: ['+'],
23026
+ label: 'stage paths',
23027
+ description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
23028
+ contexts: ['status', 'compose'],
23029
+ },
22982
23030
  {
22983
23031
  id: 'viewChangelog',
22984
23032
  keys: ['L'],
@@ -23052,6 +23100,19 @@ const GLOBAL_BINDING_IDS = [
23052
23100
  'navigateBack',
23053
23101
  ];
23054
23102
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
23103
+ /**
23104
+ * Narrow single-pane footer budget (#1135). On terminals below the
23105
+ * single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
23106
+ * the snap-back / peek affordance already claim most of an 80-cell row,
23107
+ * so the per-view hint tail and the global cluster are trimmed to what
23108
+ * fits without clipping — the switcher is the orientation anchor and
23109
+ * must stay whole. The dropped bindings remain one `?` (help) away.
23110
+ *
23111
+ * - keep only the first view hint (the most actionable for the view)
23112
+ * - shrink the global cluster to the two recovery essentials
23113
+ */
23114
+ const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
23115
+ const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
23055
23116
  /**
23056
23117
  * Per-binding category mapping. Used to subdivide the help overlay's
23057
23118
  * Global and view sections into named clusters so users don't face a
@@ -23072,6 +23133,9 @@ const BINDING_CATEGORY_BY_ID = {
23072
23133
  openProjectConfig: 'view',
23073
23134
  openGlobalConfig: 'view',
23074
23135
  gitignoreFile: 'mutate',
23136
+ stageAll: 'mutate',
23137
+ stagePathspec: 'mutate',
23138
+ createStash: 'mutate',
23075
23139
  quit: 'essentials',
23076
23140
  refresh: 'essentials',
23077
23141
  navigateBack: 'essentials',
@@ -23223,18 +23287,20 @@ function formatLogInkBreadcrumb(viewStack) {
23223
23287
  if (viewStack.length === 1 && viewStack[0] === 'history') {
23224
23288
  return '';
23225
23289
  }
23226
- // Trailing back-hint (P2.5) reminds the user how to walk back when
23227
- // they're nested deeper than the root view.
23228
- return `${viewStack.join(' › ')} ← <`;
23290
+ // Pure location breadcrumb no trailing back-hint. The footer's
23291
+ // global `< back` hint already names the walk-back key, so repeating
23292
+ // `← <` on every nested view was redundant header chrome (TUI audit).
23293
+ return viewStack.join(' › ');
23229
23294
  }
23230
23295
  /**
23231
23296
  * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
23232
23297
  * for the chrome header. Returns an empty string for a root-only stack
23233
23298
  * so the header stays compact when nothing has been pushed.
23234
23299
  *
23235
- * The trailing `← esc` reminds the user that Esc is the way out — same
23236
- * shape as the view breadcrumb's `← <` so the two read consistently.
23237
- * The repo breadcrumb shows in addition to the view breadcrumb when
23300
+ * The trailing `← esc` reminds the user that Esc (not `<`) pops the
23301
+ * repo stack a distinct key from the footer's global `< back`, so
23302
+ * unlike the view breadcrumb (pure location) the repo crumb keeps its
23303
+ * hint. The repo breadcrumb shows in addition to the view breadcrumb when
23238
23304
  * both stacks are non-trivial; the chrome layer is responsible for
23239
23305
  * laying them out side by side.
23240
23306
  *
@@ -23277,7 +23343,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
23277
23343
  }
23278
23344
  return '';
23279
23345
  }
23346
+ /**
23347
+ * Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
23348
+ * The active pane (derived from focus: sidebar → sidebar, detail →
23349
+ * inspector, otherwise main) is bracketed so the user can see which of
23350
+ * the three panes Tab will move them away from. Surfaced only on narrow
23351
+ * terminals where the other two panes aren't on screen.
23352
+ */
23353
+ function singlePaneSwitcherHint(focus) {
23354
+ const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
23355
+ const label = (pane) => (pane === active ? `[${pane}]` : pane);
23356
+ return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
23357
+ }
23280
23358
  function getLogInkFooterHints(options) {
23359
+ const hints = computeLogInkFooterHints(options);
23360
+ // While peeking the sidebar (#1135 v2) the footer shows the snap-back
23361
+ // affordance instead of the switcher — the user is mid-glance, not
23362
+ // navigating, so `v`/Esc returning to main is the relevant action. The
23363
+ // view-hint tail + globals are trimmed to fit the narrow row (see
23364
+ // SINGLE_PANE_GLOBAL_HINTS).
23365
+ if (options.peeking) {
23366
+ return {
23367
+ contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
23368
+ global: SINGLE_PANE_GLOBAL_HINTS,
23369
+ };
23370
+ }
23371
+ // On narrow terminals only one pane is on screen, so prepend a Tab
23372
+ // pane switcher for orientation. The caller (footer) only sets
23373
+ // `singlePane` in the plain per-pane states — while an overlay or
23374
+ // filter owns the screen the visible pane is forced (or input is
23375
+ // captured) and Tab does something else, so the switcher is
23376
+ // suppressed there to avoid showing a pane that isn't active. From the
23377
+ // main / inspector pane we also surface `v peek` so the momentary
23378
+ // sidebar glance is discoverable. The full per-view hint cluster +
23379
+ // global cluster don't fit alongside the switcher at the 80-col floor,
23380
+ // so both are trimmed (the dropped keys stay reachable via `?`).
23381
+ if (options.singlePane) {
23382
+ const lead = options.focus === 'sidebar'
23383
+ ? [singlePaneSwitcherHint(options.focus)]
23384
+ : [singlePaneSwitcherHint(options.focus), 'v peek'];
23385
+ return {
23386
+ contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
23387
+ global: SINGLE_PANE_GLOBAL_HINTS,
23388
+ };
23389
+ }
23390
+ return hints;
23391
+ }
23392
+ function computeLogInkFooterHints(options) {
23281
23393
  if (options.pendingKey) {
23282
23394
  const continuations = getLogInkChordContinuations(options.pendingKey);
23283
23395
  if (continuations.length > 0) {
@@ -23394,7 +23506,7 @@ function getLogInkFooterHints(options) {
23394
23506
  }
23395
23507
  if (options.activeView === 'status') {
23396
23508
  return {
23397
- contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
23509
+ contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
23398
23510
  global: NORMAL_GLOBAL_HINTS,
23399
23511
  };
23400
23512
  }
@@ -23406,16 +23518,19 @@ function getLogInkFooterHints(options) {
23406
23518
  const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
23407
23519
  if (options.diffSource === 'stash') {
23408
23520
  return {
23409
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
23521
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
23410
23522
  global: NORMAL_GLOBAL_HINTS,
23411
23523
  };
23412
23524
  }
23413
23525
  if (options.diffSource === 'commit') {
23414
23526
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
23415
23527
  // cursored file from the commit into the worktree, and `H`
23416
- // (or `gH` for index) applies just the cursored hunk.
23528
+ // (or `gH` for index) applies just the cursored hunk. `j/k`
23529
+ // line-scroll the diff body; `[`/`]` jump between hunks — the
23530
+ // footer labels match the actual handlers (commit diff has no
23531
+ // per-file `[/]` jump; that's the stash diff).
23417
23532
  return {
23418
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
23533
+ contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
23419
23534
  global: NORMAL_GLOBAL_HINTS,
23420
23535
  };
23421
23536
  }
@@ -23428,14 +23543,17 @@ function getLogInkFooterHints(options) {
23428
23543
  global: NORMAL_GLOBAL_HINTS,
23429
23544
  };
23430
23545
  }
23546
+ // Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
23547
+ // hunks, space stages/unstages the selected one, a stages the whole
23548
+ // file, z discards the hunk.
23431
23549
  return {
23432
- contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
23550
+ contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
23433
23551
  global: NORMAL_GLOBAL_HINTS,
23434
23552
  };
23435
23553
  }
23436
23554
  if (options.activeView === 'compose') {
23437
23555
  return {
23438
- contextual: ['e edit', 'E $EDITOR', 'c commit', 'S split', 'I AI draft', 'gs hunks', 'esc back'],
23556
+ contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
23439
23557
  global: NORMAL_GLOBAL_HINTS,
23440
23558
  };
23441
23559
  }
@@ -23465,7 +23583,7 @@ function getLogInkFooterHints(options) {
23465
23583
  }
23466
23584
  if (options.activeView === 'stash') {
23467
23585
  return {
23468
- contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', 'X drop', 'y yank'],
23586
+ contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
23469
23587
  global: NORMAL_GLOBAL_HINTS,
23470
23588
  };
23471
23589
  }
@@ -24856,7 +24974,7 @@ function topOfStack(stack) {
24856
24974
  }
24857
24975
  function withPushedView(state, value) {
24858
24976
  if (topOfStack(state.viewStack) === value) {
24859
- return { ...state, pendingKey: undefined };
24977
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
24860
24978
  }
24861
24979
  const viewStack = [...state.viewStack, value];
24862
24980
  return {
@@ -24879,12 +24997,15 @@ function withPushedView(state, value) {
24879
24997
  compareHead: value === 'diff' ? state.compareHead : undefined,
24880
24998
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24881
24999
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
25000
+ // Changing the view is a deliberate destination — cancel any pending
25001
+ // peek return so the user isn't snapped back afterward.
25002
+ peekReturnFocus: undefined,
24882
25003
  pendingKey: undefined,
24883
25004
  };
24884
25005
  }
24885
25006
  function withPoppedView(state) {
24886
25007
  if (state.viewStack.length <= 1) {
24887
- return { ...state, pendingKey: undefined };
25008
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
24888
25009
  }
24889
25010
  const viewStack = state.viewStack.slice(0, -1);
24890
25011
  const next = topOfStack(viewStack);
@@ -24911,6 +25032,8 @@ function withPoppedView(state) {
24911
25032
  compareHead: next === 'diff' ? state.compareHead : undefined,
24912
25033
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
24913
25034
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
25035
+ // Backing out is a deliberate navigation — cancel any peek return.
25036
+ peekReturnFocus: undefined,
24914
25037
  pendingKey: undefined,
24915
25038
  };
24916
25039
  }
@@ -25023,7 +25146,7 @@ function withPoppedRepoFrame(state) {
25023
25146
  }
25024
25147
  function withReplacedView(state, value) {
25025
25148
  if (topOfStack(state.viewStack) === value) {
25026
- return { ...state, pendingKey: undefined };
25149
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
25027
25150
  }
25028
25151
  const viewStack = [...state.viewStack.slice(0, -1), value];
25029
25152
  return {
@@ -25037,6 +25160,9 @@ function withReplacedView(state, value) {
25037
25160
  compareHead: value === 'diff' ? state.compareHead : undefined,
25038
25161
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
25039
25162
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
25163
+ // Changing the view is a deliberate destination — cancel any pending
25164
+ // peek return so the user isn't snapped back afterward.
25165
+ peekReturnFocus: undefined,
25040
25166
  pendingKey: undefined,
25041
25167
  };
25042
25168
  }
@@ -25270,6 +25396,9 @@ function applyLogInkAction(state, action) {
25270
25396
  // from 'commits' should always land back on a real file when
25271
25397
  // the user returns.
25272
25398
  statusGroupHeaderFocused: false,
25399
+ // Explicit focus cycle cancels a pending peek return — the
25400
+ // user has taken manual control of the focus.
25401
+ peekReturnFocus: undefined,
25273
25402
  pendingKey: undefined,
25274
25403
  };
25275
25404
  case 'focusPrevious':
@@ -25278,6 +25407,7 @@ function applyLogInkAction(state, action) {
25278
25407
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
25279
25408
  sidebarHeaderFocused: false,
25280
25409
  statusGroupHeaderFocused: false,
25410
+ peekReturnFocus: undefined,
25281
25411
  pendingKey: undefined,
25282
25412
  };
25283
25413
  case 'move':
@@ -25775,8 +25905,35 @@ function applyLogInkAction(state, action) {
25775
25905
  // the status view — clear when focus moves away so a
25776
25906
  // re-entry starts on a real file.
25777
25907
  statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
25908
+ // An explicit focus set cancels a pending peek return.
25909
+ peekReturnFocus: undefined,
25910
+ pendingKey: undefined,
25911
+ };
25912
+ case 'togglePeek': {
25913
+ // Peek = "focus the sidebar with a return ticket." Closing returns
25914
+ // to the stashed focus; opening (only from a non-sidebar pane)
25915
+ // stashes the current focus and jumps to the sidebar. The render
25916
+ // layer needs no special case — `focus: 'sidebar'` already drives
25917
+ // the single-pane layout to show the sidebar full-width.
25918
+ if (state.peekReturnFocus !== undefined) {
25919
+ return {
25920
+ ...state,
25921
+ focus: state.peekReturnFocus,
25922
+ peekReturnFocus: undefined,
25923
+ sidebarHeaderFocused: false,
25924
+ pendingKey: undefined,
25925
+ };
25926
+ }
25927
+ if (state.focus === 'sidebar') {
25928
+ return state;
25929
+ }
25930
+ return {
25931
+ ...state,
25932
+ focus: 'sidebar',
25933
+ peekReturnFocus: state.focus,
25778
25934
  pendingKey: undefined,
25779
25935
  };
25936
+ }
25780
25937
  case 'setPendingKey':
25781
25938
  return {
25782
25939
  ...state,
@@ -26643,6 +26800,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
26643
26800
  return [action({ type: 'toggleGraph' })];
26644
26801
  case 'navigateHome':
26645
26802
  return [action({ type: 'navigateHome' })];
26803
+ case 'createStash':
26804
+ return [action({
26805
+ type: 'openInputPrompt',
26806
+ kind: 'create-stash',
26807
+ label: 'Stash message (empty = WIP)',
26808
+ })];
26646
26809
  case 'navigateStatus':
26647
26810
  return [action({ type: 'pushView', value: 'status' })];
26648
26811
  case 'navigateDiff':
@@ -26748,6 +26911,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
26748
26911
  // Runtime resolves the cursored worktree file and opens the picker
26749
26912
  // (no-ops with a warning when there's no file under the cursor).
26750
26913
  return [{ type: 'openGitignorePicker' }];
26914
+ case 'stageAll':
26915
+ return [{ type: 'runWorkflowAction', id: 'stage-all' }];
26916
+ case 'stagePathspec':
26917
+ return [action({
26918
+ type: 'openInputPrompt',
26919
+ kind: 'stage-pathspec',
26920
+ label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
26921
+ })];
26751
26922
  case 'workflowDeleteBranch':
26752
26923
  case 'workflowDeleteTag':
26753
26924
  case 'workflowDropStash':
@@ -26838,6 +27009,15 @@ function submitInputPrompt(state) {
26838
27009
  if (!state.inputPrompt)
26839
27010
  return [];
26840
27011
  const value = state.inputPrompt.value.trim();
27012
+ // create-stash allows an EMPTY value → quick WIP stash (git supplies its
27013
+ // own "WIP on <branch>" subject). Handled before the generic empty guard
27014
+ // so an empty stash prompt commits a WIP stash instead of bouncing.
27015
+ if (state.inputPrompt.kind === 'create-stash') {
27016
+ return [
27017
+ { type: 'runWorkflowAction', id: 'create-stash', payload: value },
27018
+ action({ type: 'closeInputPrompt' }),
27019
+ ];
27020
+ }
26841
27021
  if (!value) {
26842
27022
  return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
26843
27023
  }
@@ -26847,6 +27027,12 @@ function submitInputPrompt(state) {
26847
27027
  action({ type: 'closeInputPrompt' }),
26848
27028
  ];
26849
27029
  }
27030
+ if (state.inputPrompt.kind === 'stage-pathspec') {
27031
+ return [
27032
+ { type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
27033
+ action({ type: 'closeInputPrompt' }),
27034
+ ];
27035
+ }
26850
27036
  if (state.inputPrompt.kind === 'reset-mode') {
26851
27037
  const mode = value.toLowerCase();
26852
27038
  if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
@@ -27073,7 +27259,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27073
27259
  // draft was pending should see the original `R` / Esc semantics of
27074
27260
  // wherever they are now.
27075
27261
  if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
27076
- if (inputValue === 'R' && !key.ctrl && !key.meta) {
27262
+ // `R` or `Enter` accept the swap (the AI draft becomes the new
27263
+ // content); `Enter` is the natural "yes, use it" confirmation.
27264
+ if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
27077
27265
  return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
27078
27266
  }
27079
27267
  if (key.escape) {
@@ -27459,6 +27647,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27459
27647
  }
27460
27648
  return events;
27461
27649
  }
27650
+ // #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
27651
+ // snaps back to the pane the user came from. Placed before the
27652
+ // generic Esc → popView so a peek glance returns to main rather than
27653
+ // walking the view stack. Every other key falls through to normal
27654
+ // handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
27655
+ // browse the sidebar and keep the peek open until an explicit exit.
27656
+ if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
27657
+ return [action({ type: 'togglePeek' })];
27658
+ }
27462
27659
  if (key.escape && state.viewStack.length > 1) {
27463
27660
  return [action({ type: 'popView' })];
27464
27661
  }
@@ -27525,6 +27722,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27525
27722
  action({ type: 'setStatus', value: 'jumped to stash' }),
27526
27723
  ];
27527
27724
  }
27725
+ // `gZ` chord: stash all changes from ANY view — including status / diff /
27726
+ // compose, where bare `S` is claimed by the commit-split flow. Mnemonic
27727
+ // pair with `gz` (jump to the stash *view*). Opens the same message
27728
+ // prompt; an empty message creates a quick WIP stash.
27729
+ if (state.pendingKey === 'g' && inputValue === 'Z') {
27730
+ return [action({
27731
+ type: 'openInputPrompt',
27732
+ kind: 'create-stash',
27733
+ label: 'Stash message (empty = WIP)',
27734
+ })];
27735
+ }
27528
27736
  if (state.pendingKey === 'g' && inputValue === 'w') {
27529
27737
  return [
27530
27738
  action({ type: 'pushView', value: 'worktrees' }),
@@ -27888,6 +28096,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27888
28096
  if (key.tab) {
27889
28097
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
27890
28098
  }
28099
+ // #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
28100
+ // narrow (single-pane) terminals: a momentary glance that snaps back
28101
+ // with `v` / Esc (handled above once peeking). No-op in the three-pane
28102
+ // layout (every pane is already on screen) and from the sidebar itself.
28103
+ if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
28104
+ return [action({ type: 'togglePeek' })];
28105
+ }
27891
28106
  // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
27892
28107
  // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
27893
28108
  // vertical axis (↑/↓ below) is "within the active tab's items".
@@ -27973,10 +28188,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27973
28188
  fileCount: context.worktreeFileCount,
27974
28189
  })];
27975
28190
  }
27976
- // Diff view: j/k scrolls the visible diff one line. Hunk navigation
27977
- // moved to ]/[ so single-hunk files (longer than the preview pane)
27978
- // can scroll bidirectionally instead of getting pinned to a hunk
27979
- // anchor.
28191
+ // Worktree (staging) diff: ↑/↓ move between hunks the hunk is the
28192
+ // unit you stage, so the cursor walks hunks (auto-scrolling to the
28193
+ // selected one). Single-hunk files fall through to line-scroll so a
28194
+ // long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
28195
+ if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
28196
+ return [action({
28197
+ type: 'jumpWorktreeHunk',
28198
+ delta: -1,
28199
+ hunkOffsets: context.worktreeHunkOffsets,
28200
+ })];
28201
+ }
27980
28202
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
27981
28203
  return [action({
27982
28204
  type: 'pageWorktreeDiff',
@@ -28091,6 +28313,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28091
28313
  fileCount: context.worktreeFileCount,
28092
28314
  })];
28093
28315
  }
28316
+ // Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
28317
+ // handler). Multi-hunk only; single-hunk files line-scroll.
28318
+ if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
28319
+ return [action({
28320
+ type: 'jumpWorktreeHunk',
28321
+ delta: 1,
28322
+ hunkOffsets: context.worktreeHunkOffsets,
28323
+ })];
28324
+ }
28094
28325
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
28095
28326
  return [action({
28096
28327
  type: 'pageWorktreeDiff',
@@ -28497,6 +28728,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28497
28728
  if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
28498
28729
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
28499
28730
  }
28731
+ // `A` applies restoring the staged/unstaged split (`git stash apply
28732
+ // --index`) — distinct from `a` (plain apply).
28733
+ if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
28734
+ return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
28735
+ }
28736
+ // `b` turns the cursored stash into a new branch (`git stash branch`).
28737
+ if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
28738
+ return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
28739
+ }
28740
+ // `R` renames the cursored stash (store-under-new-message + drop old).
28741
+ if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
28742
+ return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
28743
+ }
28744
+ // `u` undoes the last drop. Gated on the view, NOT the count, so it
28745
+ // still works right after you drop your only stash (the list is empty
28746
+ // but the dropped commit is recoverable by hash).
28747
+ if (inputValue === 'u' && isStashActionTarget(state)) {
28748
+ return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
28749
+ }
28500
28750
  // Per-view tag action: `P` pushes the selected tag to origin. Letter
28501
28751
  // is scoped to the tags target so it doesn't collide with `p` for
28502
28752
  // pop-stash. Note: this also takes precedence over the global
@@ -28725,7 +28975,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28725
28975
  return [action({
28726
28976
  type: 'openInputPrompt',
28727
28977
  kind: 'create-stash',
28728
- label: 'Stash message',
28978
+ label: 'Stash message (empty = WIP)',
28729
28979
  })];
28730
28980
  }
28731
28981
  // `o` opens the file under the cursor in $EDITOR. Available on the
@@ -28994,9 +29244,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28994
29244
  if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
28995
29245
  return [{ type: 'toggleSelectedFileStage' }];
28996
29246
  }
29247
+ // `A` — stage everything (git add -A); `+` — stage by typed pathspec.
29248
+ // Both available from the status AND compose views so you can stage
29249
+ // without leaving the message editor.
29250
+ if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
29251
+ return [{ type: 'runWorkflowAction', id: 'stage-all' }];
29252
+ }
29253
+ if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
29254
+ return [action({
29255
+ type: 'openInputPrompt',
29256
+ kind: 'stage-pathspec',
29257
+ label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
29258
+ })];
29259
+ }
28997
29260
  if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
28998
29261
  return [{ type: 'toggleSelectedHunkStage' }];
28999
29262
  }
29263
+ // Worktree diff with no hunks (a new/untracked file) — `space` stages
29264
+ // the whole file, since there's nothing to partial-stage.
29265
+ if (inputValue === ' ' &&
29266
+ state.activeView === 'diff' &&
29267
+ state.diffSource === 'worktree' &&
29268
+ !context.worktreeHunkOffsets?.length) {
29269
+ return [{ type: 'toggleSelectedFileStage' }];
29270
+ }
29271
+ // `a` stages/unstages the WHOLE current file from the staging diff —
29272
+ // an escape hatch out of hunk-by-hunk back to all-or-nothing.
29273
+ if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
29274
+ return [{ type: 'toggleSelectedFileStage' }];
29275
+ }
29000
29276
  if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
29001
29277
  return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
29002
29278
  }
@@ -29771,21 +30047,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
29771
30047
  * wide >= 160 — plenty of room; keep absolute dates
29772
30048
  * normal >= 120 — relative dates save 8-ish cells without hiding info
29773
30049
  * tight >= 100 — drop date entirely; subject + refs are the priority
29774
- * rail < 100 — even with side panels collapsed the row is tight;
29775
- * stack to two lines and rail the side panels at rest
30050
+ * rail < 100 — history rows stack to two lines; the UI also drops
30051
+ * to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
29776
30052
  */
29777
30053
  const LAYOUT_TIGHT_BELOW = 120;
29778
30054
  const LAYOUT_NORMAL_BELOW = 160;
29779
30055
  const LAYOUT_RAIL_BELOW = 100;
29780
30056
  /**
29781
- * Fixed cell width for a railed side panel. Just wide enough for a
29782
- * 1-cell icon + a 2-3 digit count after subtracting border (2) and
29783
- * padding (2). Going narrower clips the count; going wider defeats
29784
- * the purpose of railing in the first place.
30057
+ * Width below which the three-panel layout can't tile without starving
30058
+ * every pane, so the UI shows exactly one full-width pane (the focused
30059
+ * one) and Tab cycles which pane is visible. Coincides with the `rail`
30060
+ * density breakpoint single-pane mode replaces the old 8-cell icon
30061
+ * rails that used to render at this width.
29785
30062
  */
29786
- const LAYOUT_RAIL_PANEL_WIDTH = 8;
30063
+ const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
29787
30064
  const SIDEBAR_AT_REST_BY_TIER = {
29788
- rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
30065
+ rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest single-pane mode overrides the width
29789
30066
  tight: { min: 22, max: 28, fraction: 0.24 },
29790
30067
  normal: { min: 22, max: 30, fraction: 0.22 },
29791
30068
  wide: { min: 28, max: 32, fraction: 0.20 },
@@ -29804,14 +30081,25 @@ function getLogInkLayout(input) {
29804
30081
  : columns >= LAYOUT_RAIL_BELOW
29805
30082
  ? 'tight'
29806
30083
  : 'rail';
29807
- // Rail collapse: only happens at the narrowest tier, and only for
29808
- // the panel that does NOT currently hold focus AND is not being
29809
- // commandeered by the help overlay. Focus always wins pressing
29810
- // tab to the sidebar pops it back open even on an 80-cell terminal
29811
- // so the user can actually use it. The help overlay also wins for
29812
- // the inspector since that's where its descriptions render.
29813
- const sidebarRailed = density === 'rail' && !input.sidebarFocused;
29814
- const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
30084
+ // Below the single-pane breakpoint the three-panel layout can't tile
30085
+ // without starving every pane, so we show exactly one full-width pane
30086
+ // the focused one and Tab cycles which pane is visible. This
30087
+ // replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
30088
+ // glyph + count and nothing actionable).
30089
+ const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
30090
+ // Which pane shows in single-pane mode. Defaults to the focused pane
30091
+ // (focus and visibility coalesce, so the existing Tab focus cycle
30092
+ // drives it). An active overlay can force a specific pane via
30093
+ // `forcedPane` so its surface isn't hidden behind whatever pane focus
30094
+ // points at.
30095
+ const focusPane = input.sidebarFocused
30096
+ ? 'sidebar'
30097
+ : input.inspectorFocused
30098
+ ? 'inspector'
30099
+ : 'main';
30100
+ const visiblePane = singlePane
30101
+ ? input.forcedPane ?? focusPane
30102
+ : focusPane;
29815
30103
  // Inspector width — at rest 20-32 cells (~22% of width), focused
29816
30104
  // 36-60 cells (~40% of width). Narrow rest state keeps the commit
29817
30105
  // graph dominant; focus expansion gives the inspector room for long
@@ -29823,42 +30111,48 @@ function getLogInkLayout(input) {
29823
30111
  // "Move focus...". Capped at 100 cells so a wide terminal doesn't
29824
30112
  // waste an absurd amount of horizontal space on the cheat sheet.
29825
30113
  //
29826
- // Rail collapse wins over the at-rest range but loses to focus and
29827
- // to the help overlay both of those represent deliberate user
29828
- // intent to read the panel.
30114
+ // (In single-pane mode these three-panel widths are recomputed below
30115
+ // so the visible pane gets the full terminal.)
29829
30116
  const detailWidth = input.helpOverlayActive
29830
30117
  ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
29831
30118
  : input.inspectorFocused
29832
30119
  ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
29833
- : inspectorRailed
29834
- ? LAYOUT_RAIL_PANEL_WIDTH
29835
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
30120
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
29836
30121
  // Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
29837
30122
  // tight stays compact (22-28), normal shrinks slightly (22-30),
29838
30123
  // wide grows naturally (28-48) so the side panel doesn't get pinned
29839
30124
  // at an arbitrary cap on big terminals while the main panel hogs
29840
30125
  // 80% of the width. Focused: 32-50 cells (~36% of width),
29841
30126
  // regardless of tier — deliberate user intent to read the sidebar
29842
- // deserves the extra width. Rail mode (narrow terminal, unfocused)
29843
- // collapses to a fixed 8-cell strip with tab glyphs only.
30127
+ // deserves the extra width.
29844
30128
  const sidebarWidth = input.sidebarFocused
29845
30129
  ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
29846
- : sidebarRailed
29847
- ? LAYOUT_RAIL_PANEL_WIDTH
29848
- : calcSidebarAtRestWidth(columns, density);
30130
+ : calcSidebarAtRestWidth(columns, density);
30131
+ // Single-pane mode: exactly one pane renders, full-width; the other
30132
+ // two are hidden (width 0), not railed. Above the breakpoint the
30133
+ // three panels tile flush across the terminal.
30134
+ const paneWidths = singlePane
30135
+ ? {
30136
+ sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
30137
+ mainPanelWidth: visiblePane === 'main' ? columns : 0,
30138
+ detailWidth: visiblePane === 'inspector' ? columns : 0,
30139
+ }
30140
+ : {
30141
+ sidebarWidth,
30142
+ mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
30143
+ detailWidth,
30144
+ };
29849
30145
  return {
29850
30146
  bodyRows: Math.max(8, rows - 5),
29851
30147
  columns,
29852
- detailWidth,
29853
- mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
29854
30148
  rows,
29855
- sidebarWidth,
29856
30149
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
29857
30150
  inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
29858
30151
  density,
29859
- sidebarRailed,
29860
- inspectorRailed,
30152
+ singlePane,
30153
+ visiblePane,
29861
30154
  historyRowMode: density === 'rail' ? 'stacked' : 'single',
30155
+ ...paneWidths,
29862
30156
  };
29863
30157
  }
29864
30158
 
@@ -30907,6 +31201,45 @@ async function highlightDiffCode(filePath, lines) {
30907
31201
  return result;
30908
31202
  }
30909
31203
 
31204
+ /**
31205
+ * Humanize raw AI-provider / LangChain error strings into a short,
31206
+ * actionable line for the compose surface.
31207
+ *
31208
+ * The underlying errors are verbose and developer-facing — e.g.
31209
+ * `executeChain: Chain execution failed: 429 You exceeded your current
31210
+ * quota …`. We classify the common failure modes (rate limit, auth,
31211
+ * network, context length) into a concise message that tells the user
31212
+ * what happened and what to do, and fall back to the original (trimmed)
31213
+ * text for anything we don't recognize. Pure + tested.
31214
+ */
31215
+ function humanizeAiError(raw) {
31216
+ const message = (raw || '').trim();
31217
+ if (!message)
31218
+ return 'AI request failed.';
31219
+ const lower = message.toLowerCase();
31220
+ // Rate limit / quota — the 429 in the screenshot.
31221
+ if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
31222
+ return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
31223
+ }
31224
+ // Auth / API key problems.
31225
+ if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
31226
+ return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
31227
+ }
31228
+ // Context window overflow.
31229
+ if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
31230
+ return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
31231
+ }
31232
+ // Network / connectivity.
31233
+ if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
31234
+ return 'Network error reaching the AI provider — check your connection, then press I to retry.';
31235
+ }
31236
+ // Unknown: strip the noisy `executeChain: Chain execution failed:`
31237
+ // prefix if present so the meaningful part leads, and keep it to one
31238
+ // line so it doesn't blow out the panel.
31239
+ const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
31240
+ return stripped.split('\n')[0];
31241
+ }
31242
+
30910
31243
  async function runAction$4(action, successMessage) {
30911
31244
  try {
30912
31245
  await action();
@@ -30950,15 +31283,104 @@ async function runAction$3(action, successMessage) {
30950
31283
  };
30951
31284
  }
30952
31285
  }
30953
- function createStash(git, message) {
31286
+ function createStash(git, message, options = {}) {
30954
31287
  const trimmedMessage = message.trim();
30955
- if (!trimmedMessage) {
30956
- return Promise.resolve({
30957
- ok: false,
30958
- message: 'Stash cancelled: empty message.',
30959
- });
31288
+ const args = ['stash', 'push'];
31289
+ // `--staged` is index-only, so untracked / `--keep-index` don't apply;
31290
+ // every other mode includes untracked (`-u`). `--keep-index` leaves the
31291
+ // index populated for an immediate follow-up commit.
31292
+ if (options.stagedOnly) {
31293
+ args.push('--staged');
31294
+ }
31295
+ else {
31296
+ args.push('-u');
31297
+ if (options.keepIndex)
31298
+ args.push('--keep-index');
31299
+ }
31300
+ if (trimmedMessage)
31301
+ args.push('-m', trimmedMessage);
31302
+ const paths = options.pathspec?.trim();
31303
+ if (paths)
31304
+ args.push('--', ...paths.split(/\s+/));
31305
+ const what = options.stagedOnly
31306
+ ? 'staged changes'
31307
+ : paths
31308
+ ? `“${paths}”`
31309
+ : options.keepIndex
31310
+ ? 'changes (index kept)'
31311
+ : '';
31312
+ const success = trimmedMessage
31313
+ ? `Created stash: ${trimmedMessage}`
31314
+ : what
31315
+ ? `Stashed ${what}`
31316
+ : 'Created WIP stash';
31317
+ return runAction$3(() => git.raw(args), success);
31318
+ }
31319
+ /**
31320
+ * Apply a stash while restoring the original staged/unstaged split via
31321
+ * `--index`. Faithfully reinstates what was staged at stash time; git
31322
+ * errors (surfaced to the user) if the index can no longer be replayed,
31323
+ * in which case plain `applyStash` is the fallback.
31324
+ */
31325
+ function applyStashKeepIndex(git, stash) {
31326
+ return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
31327
+ }
31328
+ /**
31329
+ * Create a new branch from a stash's base commit, apply the stash onto
31330
+ * it, and drop the stash on success — `git stash branch`. The canonical
31331
+ * recovery when a stash no longer applies cleanly onto the current
31332
+ * branch (the branch starts at the exact commit the stash was made on).
31333
+ */
31334
+ function stashBranch(git, stash, branchName) {
31335
+ const trimmed = branchName.trim();
31336
+ if (!trimmed) {
31337
+ return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
31338
+ }
31339
+ return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
31340
+ }
31341
+ /**
31342
+ * Rename a stash. Git has no native rename, so: drop the original entry,
31343
+ * then re-store the SAME commit under the new message.
31344
+ *
31345
+ * Order matters — and it's the OPPOSITE of what you'd guess. `git stash
31346
+ * store` SILENTLY NO-OPS when the commit is already referenced in the
31347
+ * stash reflog (verified empirically), so storing first does nothing and
31348
+ * a follow-up drop removes the wrong entry. Dropping first removes the
31349
+ * reflog reference (the commit object survives), so the subsequent
31350
+ * `store` actually re-adds it — landing at `stash@{0}` with the new
31351
+ * message. The commit is captured by hash beforehand, so the drop→store
31352
+ * window can't lose it.
31353
+ */
31354
+ function renameStash(git, stash, newMessage) {
31355
+ const trimmed = newMessage.trim();
31356
+ if (!trimmed) {
31357
+ return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
31358
+ }
31359
+ if (!stash.hash) {
31360
+ return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
31361
+ }
31362
+ // Preserve git's `On <branch>: <subject>` convention so the renamed
31363
+ // stash keeps its origin-branch context. The list + inspector parse the
31364
+ // branch out of that prefix (`parseStashSubject`); a bare message would
31365
+ // render `on <unknown>`. Falls back to the bare message when the branch
31366
+ // is unknown so we never store a misleading `On <unknown>:`.
31367
+ const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
31368
+ const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
31369
+ return runAction$3(async () => {
31370
+ await git.raw(['stash', 'drop', stash.ref]);
31371
+ await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
31372
+ }, `Renamed ${stash.ref} → ${trimmed}`);
31373
+ }
31374
+ /**
31375
+ * Re-store a previously dropped stash by its commit hash — the undo for
31376
+ * a `dropStash`. The dropped stash's commit stays in the object database
31377
+ * until git gc, so storing it back recreates the entry (at `stash@{0}`).
31378
+ */
31379
+ function restoreStash(git, hash, message) {
31380
+ if (!hash) {
31381
+ return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
30960
31382
  }
30961
- return runAction$3(() => git.raw(['stash', 'push', '-u', '-m', trimmedMessage]), `Created stash: ${trimmedMessage}`);
31383
+ return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
30962
31384
  }
30963
31385
  function applyStash(git, stash) {
30964
31386
  return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
@@ -31831,6 +32253,28 @@ function unstageAllFiles(git, files) {
31831
32253
  }
31832
32254
  return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
31833
32255
  }
32256
+ /**
32257
+ * Stage everything in the worktree — modifications, new files, and
32258
+ * deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
32259
+ * palette's "stage all" both route here.
32260
+ */
32261
+ function stageAll(git) {
32262
+ return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
32263
+ }
32264
+ /**
32265
+ * Stage files matching one or more git pathspecs (`git add -- <spec…>`).
32266
+ * Powers the typed "stage…" prompt (`+`): the user types a path, a
32267
+ * directory, a glob like `*.ts`, or a space-separated list, and git's
32268
+ * own pathspec matching does the rest. Args are passed directly (no
32269
+ * shell), so the globs are interpreted by git, not the shell.
32270
+ */
32271
+ function stagePathspec(git, pathspec) {
32272
+ const specs = pathspec.trim().split(/\s+/).filter(Boolean);
32273
+ if (specs.length === 0) {
32274
+ return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
32275
+ }
32276
+ return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
32277
+ }
31834
32278
 
31835
32279
  function hunkHeader(hunk) {
31836
32280
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -32407,7 +32851,7 @@ function buildLoadedHashSet(commits) {
32407
32851
  * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
32408
32852
  * colors + glyphs added in the same pass.
32409
32853
  */
32410
- function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
32854
+ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
32411
32855
  const { Box, Text } = components;
32412
32856
  // Sidebar item count drives the per-tab footer hints — when items are
32413
32857
  // present the footer surfaces in-sidebar ops (checkout / apply / pop /
@@ -32421,6 +32865,23 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32421
32865
  default: return undefined;
32422
32866
  }
32423
32867
  })();
32868
+ // The single-pane pane switcher only makes sense in the plain
32869
+ // per-pane states. While an overlay or filter owns the screen the
32870
+ // visible pane is forced (split-plan → main; help / palette / theme /
32871
+ // gitignore / input prompt / confirmation / chord → inspector) or
32872
+ // input is captured, and Tab does something else — so the switcher
32873
+ // would point at a pane that isn't on screen. Suppress it then. Mirror
32874
+ // of the runtime's `forcedPane` derivation in `app.ts`.
32875
+ const overlayForcesPane = Boolean(state.splitPlan ||
32876
+ state.showHelp ||
32877
+ state.showCommandPalette ||
32878
+ state.showThemePicker ||
32879
+ state.gitignorePicker ||
32880
+ state.inputPrompt ||
32881
+ state.pendingConfirmationId ||
32882
+ state.pendingMutationConfirmation ||
32883
+ state.pendingKey ||
32884
+ state.filterMode);
32424
32885
  const hints = getLogInkFooterHints({
32425
32886
  activeView: state.activeView,
32426
32887
  diffSource: state.diffSource,
@@ -32434,6 +32895,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32434
32895
  sidebarItemCount,
32435
32896
  compareBaseSet: Boolean(state.compareBase),
32436
32897
  splitPlanStatus: state.splitPlan?.status,
32898
+ singlePane: singlePane && !overlayForcesPane,
32899
+ // Peeking (#1135 v2) is a single-pane glance with focus on the
32900
+ // sidebar; the footer shows `v/esc → main` instead of the switcher.
32901
+ // Suppressed under an overlay (which owns the footer) just like the
32902
+ // switcher.
32903
+ peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
32437
32904
  });
32438
32905
  // Real status messages always win; idle tips only fill the slot when it
32439
32906
  // would otherwise be empty.
@@ -32929,7 +33396,10 @@ function sidebarTabCount(tab, context) {
32929
33396
  * Header chip builder. Turns the workstation's title-bar state into an
32930
33397
  * ordered list of small visually-distinct chips:
32931
33398
  *
32932
- * coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
33399
+ * coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
33400
+ *
33401
+ * The PR chip is appended only when a pull request exists (#1133); there
33402
+ * is no "no PR" placeholder chip.
32933
33403
  *
32934
33404
  * Pre-refactor the title bar concatenated every segment into a single
32935
33405
  * Text span, which made the eye read the whole thing as one run of
@@ -33025,10 +33495,11 @@ function buildHeaderChips(input) {
33025
33495
  bold: true,
33026
33496
  });
33027
33497
  }
33028
- // PR state. When present, the chip uses the PR-state glyph + a short
33029
- // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
33030
- // "no PR" chip so users know the system DID look (vs. the bar just
33031
- // being blank).
33498
+ // PR state. Shown only when a PR actually exists the chip uses the
33499
+ // PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
33500
+ // The old always-on "no PR" chip spent a permanent header segment to
33501
+ // report a negative default state on every screen; dropping it keeps
33502
+ // the state cluster about what *is* true (TUI audit).
33032
33503
  if (input.pullRequest) {
33033
33504
  const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
33034
33505
  const stateLabel = input.pullRequest.isDraft
@@ -33045,15 +33516,6 @@ function buildHeaderChips(input) {
33045
33516
  bold: false,
33046
33517
  });
33047
33518
  }
33048
- else {
33049
- chips.push({
33050
- id: 'pr',
33051
- label: theme.ascii ? '- no PR' : '⊘ no PR',
33052
- color: theme.colors.muted,
33053
- dim: true,
33054
- bold: false,
33055
- });
33056
- }
33057
33519
  // View breadcrumb. Rendered only when there's content (`coco ui`
33058
33520
  // root view → no breadcrumb chip; pushed into a sub-view → chip
33059
33521
  // appears). Comes AFTER PR so the "state" group (app/repo/branch/
@@ -33124,7 +33586,10 @@ function measureHeaderChipsWidth(chips) {
33124
33586
  * Title-bar renderer. Surfaces the workstation's identity + navigation
33125
33587
  * state as a row of small visually-distinct chips:
33126
33588
  *
33127
- * coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
33589
+ * coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
33590
+ *
33591
+ * The PR chip is appended only when a pull request exists (e.g.
33592
+ * `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
33128
33593
  *
33129
33594
  * Per-chip color/glyph treatment lets the user scan in chunks ("what
33130
33595
  * app, what repo, what branch, how clean, what PR state, what mode")
@@ -33564,70 +34029,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
33564
34029
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
33565
34030
  }, 'tab-worktrees', visibleListCount);
33566
34031
  }
33567
- /**
33568
- * Single-letter glyph for a sidebar tab in rail mode. Letters always
33569
- * carry the meaning so this stays useful under ASCII; the rail is too
33570
- * narrow to fit the full tab label. Pairs with `sidebarTabCount` for
33571
- * the trailing count.
33572
- */
33573
- function sidebarTabRailGlyph(tab) {
33574
- switch (tab) {
33575
- case 'status':
33576
- return 'S';
33577
- case 'branches':
33578
- return 'B';
33579
- case 'tags':
33580
- return 'T';
33581
- case 'stashes':
33582
- return '$';
33583
- case 'worktrees':
33584
- return 'W';
33585
- default:
33586
- return '·';
33587
- }
33588
- }
33589
- /**
33590
- * Rail-mode sidebar — shown on terminals < 100 columns when the
33591
- * sidebar does not hold focus. Five vertically stacked tab glyphs
33592
- * with optional counts; the active tab is bracketed. Pressing Tab to
33593
- * focus the sidebar pops it back to the full accordion (the layout
33594
- * un-rails it on focus, this renderer is never called in that case).
33595
- */
33596
- function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
33597
- const { Box, Text } = components;
33598
- return h(Box, {
33599
- borderColor: focusBorderColor(theme, focused),
33600
- borderStyle: theme.borderStyle,
33601
- flexDirection: 'column',
33602
- width,
33603
- paddingX: 1,
33604
- }, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
33605
- const isActive = tab === state.sidebarTab;
33606
- const glyph = sidebarTabRailGlyph(tab);
33607
- const count = sidebarTabCount(tab, context);
33608
- // Count fits in 2 cells (rail content area is ~4 cells); 99+
33609
- // collapses to `+` so we never overflow.
33610
- const countText = count === undefined
33611
- ? ''
33612
- : count > 99
33613
- ? '+'
33614
- : String(count);
33615
- const body = isActive ? `[${glyph}]` : ` ${glyph} `;
33616
- const text = countText ? `${body}${countText}` : body;
33617
- return h(Text, {
33618
- key: `rail-${tab}`,
33619
- bold: isActive,
33620
- dimColor: !isActive,
33621
- }, text);
33622
- }));
33623
- }
33624
- function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
34032
+ function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
33625
34033
  const { Box, Text } = components;
33626
34034
  const focused = state.focus === 'sidebar';
33627
34035
  const tabs = getLogInkSidebarTabs();
33628
- if (railed) {
33629
- return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
33630
- }
33631
34036
  // Accordion layout — every tab's title is visible on its own line, but
33632
34037
  // only the active tab expands its content underneath. Switching tabs
33633
34038
  // (1-5 / [/]) collapses the previous and expands the next.
@@ -33686,7 +34091,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
33686
34091
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
33687
34092
  * of #890. No behavior change.
33688
34093
  */
33689
- function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
34094
+ function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
34095
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
33690
34096
  const { Box, Text } = components;
33691
34097
  const focused = state.focus === 'commits';
33692
34098
  const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
@@ -34010,7 +34416,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
34010
34416
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
34011
34417
  * of #890. No behavior change.
34012
34418
  */
34013
- function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
34419
+ function renderBranchesSurface(ctx) {
34420
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34014
34421
  const { Box, Text } = components;
34015
34422
  const focused = state.focus === 'commits';
34016
34423
  const branches = context.branches;
@@ -34156,7 +34563,8 @@ function formatCacheAge(generatedAt, now) {
34156
34563
  const day = Math.floor(hr / 24);
34157
34564
  return `${day}d ago`;
34158
34565
  }
34159
- function renderChangelogSurface(h, components, state, _context, _contextStatus, bodyRows, width, theme) {
34566
+ function renderChangelogSurface(ctx) {
34567
+ const { h, components, state, bodyRows, width, theme } = ctx;
34160
34568
  const { Box, Text } = components;
34161
34569
  const focused = state.focus === 'commits';
34162
34570
  const view = state.changelogView;
@@ -34348,7 +34756,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
34348
34756
  }, `${prefix}${line}`);
34349
34757
  });
34350
34758
  }
34351
- function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
34759
+ function renderComposeSurface(ctx, spinnerFrame = 0) {
34760
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34352
34761
  const { Box, Text } = components;
34353
34762
  const compose = state.commitCompose;
34354
34763
  const focused = state.focus === 'commits';
@@ -34461,7 +34870,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
34461
34870
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
34462
34871
  * of #890. No behavior change.
34463
34872
  */
34464
- function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
34873
+ function renderConflictsSurface(ctx) {
34874
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34465
34875
  const { Box, Text } = components;
34466
34876
  const focused = state.focus === 'commits';
34467
34877
  const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
@@ -34930,6 +35340,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34930
35340
  return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
34931
35341
  }
34932
35342
 
35343
+ /** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
35344
+ function hunkIndexForLine(absLine, hunkOffsets) {
35345
+ let index = -1;
35346
+ for (let k = 0; k < hunkOffsets.length; k++) {
35347
+ if (hunkOffsets[k] <= absLine)
35348
+ index = k;
35349
+ else
35350
+ break;
35351
+ }
35352
+ return index;
35353
+ }
35354
+ function renderWorktreeDiffBody(h, components, params) {
35355
+ const { Box, Text } = components;
35356
+ const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
35357
+ const headerSet = new Set(hunkOffsets);
35358
+ const accent = theme.noColor ? undefined : theme.colors.accent;
35359
+ const added = theme.noColor ? undefined : theme.colors.gitAdded;
35360
+ const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
35361
+ const visible = lines.slice(offset, offset + visibleRows);
35362
+ return visible.map((line, i) => {
35363
+ const abs = offset + i;
35364
+ const key = `${keyPrefix}-${abs}`;
35365
+ const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
35366
+ const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
35367
+ const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
35368
+ const isStaged = hunk?.state === 'staged';
35369
+ const bar = isSelected ? '▎' : ' ';
35370
+ // `@@` header row — badge + (dim) hunk position, emphasized when selected.
35371
+ if (headerSet.has(abs)) {
35372
+ const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
35373
+ const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
35374
+ 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)));
35375
+ }
35376
+ // Body / context / pre-hunk lines.
35377
+ // A staged hunk that ISN'T selected renders dim ("done", out of
35378
+ // focus); the selected hunk and unstaged hunks keep full diff +
35379
+ // syntax coloring via renderDiffLine so the focus stays vivid.
35380
+ const content = isStaged && !isSelected && hunkIndex >= 0
35381
+ ? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
35382
+ : renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
35383
+ return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
35384
+ });
35385
+ }
35386
+
34933
35387
  /**
34934
35388
  * Diff surface — the unified or side-by-side diff view. Four sources
34935
35389
  * route through here, disambiguated by `state.diffSource`:
@@ -34952,7 +35406,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34952
35406
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
34953
35407
  * of #890. No behavior change.
34954
35408
  */
34955
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
35409
+ function renderDiffSurface(ctx, diff) {
35410
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
35411
+ const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
34956
35412
  const { Box, Text } = components;
34957
35413
  const focused = state.focus === 'commits';
34958
35414
  const worktree = context.worktree;
@@ -35152,7 +35608,22 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35152
35608
  }
35153
35609
  const diffLines = worktreeDiff?.lines || [];
35154
35610
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
35611
+ const totalHunks = worktreeHunks?.hunks.length ?? 0;
35612
+ const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
35155
35613
  const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
35614
+ // Hunk-position line: badge + selected hunk's state + a staged/total
35615
+ // progress count, so the user always sees how far through staging they
35616
+ // are. Untracked/new files have no hunks — point them at whole-file
35617
+ // staging instead of a dead-end "no hunks" message.
35618
+ const hunkHeaderLine = worktreeHunksLoading
35619
+ ? 'Hunks loading…'
35620
+ : worktreeDiff?.untracked
35621
+ ? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
35622
+ : totalHunks
35623
+ ? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
35624
+ ? (theme.ascii ? '[x] staged' : '● staged')
35625
+ : (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
35626
+ : 'No stageable hunks for this file.';
35156
35627
  const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
35157
35628
  ? ['Loading file context...']
35158
35629
  : worktreeDiffLoading
@@ -35161,11 +35632,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35161
35632
  ? [
35162
35633
  // File path is already shown in the panel title bar (right) —
35163
35634
  // no redundant "Selected file:" line here.
35164
- worktreeHunksLoading
35165
- ? 'Hunks loading...'
35166
- : worktreeHunks?.hunks.length
35167
- ? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
35168
- : 'No stageable hunks for this file.',
35635
+ hunkHeaderLine,
35169
35636
  `Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
35170
35637
  '',
35171
35638
  ]
@@ -35180,11 +35647,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35180
35647
  flexShrink: 0,
35181
35648
  paddingX: 1,
35182
35649
  width,
35183
- }, 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, {
35650
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
35651
+ // Use the path of the file actually being diffed (the grouped/visible
35652
+ // selection feeds the loaded diff) — `worktreeFile` indexes the raw,
35653
+ // ungrouped file list and can name a different file than the diff body.
35654
+ h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
35184
35655
  key: `diff-surface-header-${index}`,
35185
35656
  dimColor: index > 0,
35186
35657
  }, truncateCells(line, 140))), ...(showDiffLines
35187
- ? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
35658
+ ? renderWorktreeDiffBody(h, components, {
35659
+ lines: diffLines,
35660
+ offset: state.worktreeDiffOffset,
35661
+ visibleRows,
35662
+ width,
35663
+ theme,
35664
+ syntaxSpans,
35665
+ hunkOffsets: worktreeDiff?.hunkOffsets || [],
35666
+ hunks: worktreeHunks?.hunks || [],
35667
+ selectedIndex: state.selectedWorktreeHunkIndex,
35668
+ keyPrefix: 'diff-surface-line',
35669
+ })
35188
35670
  : []));
35189
35671
  }
35190
35672
 
@@ -36368,7 +36850,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
36368
36850
  height: innerHeight,
36369
36851
  }, 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.')));
36370
36852
  }
36371
- function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
36853
+ function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
36854
+ const { h, components, state, context, bodyRows, width, theme } = ctx;
36372
36855
  const { Box, Text } = components;
36373
36856
  const focused = state.focus === 'commits';
36374
36857
  // Remote op in flight (fetch / pull / push) → swap the commit list
@@ -37070,7 +37553,8 @@ function matchesIssueFilter(issue, filter) {
37070
37553
  ...(issue.assignees || []),
37071
37554
  ], filter);
37072
37555
  }
37073
- function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
37556
+ function renderIssuesTriageSurface(ctx) {
37557
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37074
37558
  const { Box, Text } = components;
37075
37559
  const focused = state.focus === 'commits';
37076
37560
  const overview = context.issueList;
@@ -37352,7 +37836,8 @@ function formatPullRequestStateLine(pr) {
37352
37836
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
37353
37837
  * of #890. No behavior change.
37354
37838
  */
37355
- function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
37839
+ function renderPullRequestSurface(ctx) {
37840
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37356
37841
  const { Box, Text } = components;
37357
37842
  const focused = state.focus === 'commits';
37358
37843
  const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
@@ -37525,7 +38010,8 @@ function matchesPullRequestFilter(pr, filter) {
37525
38010
  ...(pr.assignees || []),
37526
38011
  ], filter);
37527
38012
  }
37528
- function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38013
+ function renderPullRequestTriageSurface(ctx) {
38014
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37529
38015
  const { Box, Text } = components;
37530
38016
  const focused = state.focus === 'commits';
37531
38017
  const overview = context.pullRequestList;
@@ -37641,7 +38127,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
37641
38127
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
37642
38128
  * of #890. No behavior change.
37643
38129
  */
37644
- function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38130
+ function renderReflogSurface(ctx) {
38131
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37645
38132
  const { Box, Text } = components;
37646
38133
  const focused = state.focus === 'commits';
37647
38134
  const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
@@ -37712,7 +38199,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
37712
38199
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
37713
38200
  * of #890. No behavior change.
37714
38201
  */
37715
- function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38202
+ function renderStashSurface(ctx) {
38203
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37716
38204
  const { Box, Text } = components;
37717
38205
  const focused = state.focus === 'commits';
37718
38206
  const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
@@ -37730,6 +38218,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
37730
38218
  : `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
37731
38219
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
37732
38220
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38221
+ const now = getRenderNow();
38222
+ // Available width for a row: box width minus the 2-cell horizontal
38223
+ // padding. Truncate to it (with a small floor) instead of a magic 140
38224
+ // so the richer meta degrades gracefully on narrow terminals.
38225
+ const rowWidth = Math.max(20, width - 2);
37733
38226
  const lines = loading
37734
38227
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
37735
38228
  : stashes.length === 0
@@ -37738,11 +38231,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
37738
38231
  const index = startIndex + offset;
37739
38232
  const isSelected = index === selected;
37740
38233
  const cursor = isSelected ? '>' : ' ';
38234
+ // Surface the metadata the StashEntry already carries — origin
38235
+ // branch, file count, and relative age — between the ref and the
38236
+ // message, so the list answers "which stash is this?" without an
38237
+ // Enter→diff round trip.
38238
+ const age = formatCompactRelativeDate(stash.date, now);
38239
+ const fileCount = stash.files.length;
38240
+ const meta = [
38241
+ stash.branch ? `on ${stash.branch}` : '',
38242
+ fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38243
+ age,
38244
+ ].filter(Boolean).join(' · ');
38245
+ const rowText = meta
38246
+ ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38247
+ : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
37741
38248
  return h(Text, {
37742
38249
  key: `stash-${index}`,
37743
38250
  bold: isSelected,
37744
38251
  dimColor: !isSelected,
37745
- }, truncateCells(`${cursor} ${stash.ref.padEnd(12)} ${stash.message}`, 140));
38252
+ }, truncateCells(rowText, rowWidth));
37746
38253
  });
37747
38254
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
37748
38255
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -37802,7 +38309,8 @@ function formatStatusFilterMask(mask) {
37802
38309
  active.push('untracked');
37803
38310
  return active.join(' + ') || 'none';
37804
38311
  }
37805
- function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38312
+ function renderStatusSurface(ctx) {
38313
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37806
38314
  const { Box, Text } = components;
37807
38315
  const focused = state.focus === 'commits';
37808
38316
  const worktree = context.worktree;
@@ -37949,7 +38457,8 @@ function flagColor(flag, theme) {
37949
38457
  return theme.colors.danger;
37950
38458
  return undefined;
37951
38459
  }
37952
- function renderSubmodulesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38460
+ function renderSubmodulesSurface(ctx) {
38461
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37953
38462
  const { Box, Text } = components;
37954
38463
  const focused = state.focus === 'commits';
37955
38464
  const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
@@ -38088,7 +38597,8 @@ function formatHyperlink(text, url, env = process.env) {
38088
38597
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
38089
38598
  * of #890. No behavior change.
38090
38599
  */
38091
- function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38600
+ function renderTagsSurface(ctx) {
38601
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38092
38602
  const { Box, Text } = components;
38093
38603
  const focused = state.focus === 'commits';
38094
38604
  const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
@@ -38170,7 +38680,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
38170
38680
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38171
38681
  * of #890. No behavior change.
38172
38682
  */
38173
- function renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38683
+ function renderWorktreesSurface(ctx) {
38684
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38174
38685
  const { Box, Text } = components;
38175
38686
  const focused = state.focus === 'commits';
38176
38687
  const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
@@ -38234,7 +38745,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
38234
38745
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
38235
38746
  * of #890. No behavior change.
38236
38747
  */
38237
- 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) {
38748
+ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
38749
+ // The universal render values now arrive bundled (#1136); only the
38750
+ // few raw values the dispatcher itself touches (split-plan overlay,
38751
+ // activeView switch) are destructured here. Surfaces receive `surface`
38752
+ // directly plus their own slices.
38753
+ const { h, components, state, bodyRows, width, theme } = surface;
38238
38754
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
38239
38755
  // detail) when active, because the content — multiple commit groups
38240
38756
  // with file lists, rationale, hunks — needs the full center width
@@ -38246,51 +38762,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
38246
38762
  return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
38247
38763
  }
38248
38764
  if (state.activeView === 'status') {
38249
- return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38765
+ return renderStatusSurface(surface);
38250
38766
  }
38251
38767
  if (state.activeView === 'diff') {
38252
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
38768
+ const diffData = {
38769
+ worktreeDiff,
38770
+ worktreeDiffLoading,
38771
+ worktreeHunks,
38772
+ worktreeHunksLoading,
38773
+ filePreview,
38774
+ filePreviewLoading,
38775
+ commitDiffHunkOffsets,
38776
+ selectedDetailFile,
38777
+ stashDiffLines,
38778
+ stashDiffLoading,
38779
+ compareDiffLines,
38780
+ compareDiffLoading,
38781
+ syntaxSpans,
38782
+ };
38783
+ return renderDiffSurface(surface, diffData);
38253
38784
  }
38254
38785
  if (state.activeView === 'compose') {
38255
- return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
38786
+ return renderComposeSurface(surface, spinnerFrame);
38256
38787
  }
38257
38788
  if (state.activeView === 'branches') {
38258
- return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38789
+ return renderBranchesSurface(surface);
38259
38790
  }
38260
38791
  if (state.activeView === 'tags') {
38261
- return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38792
+ return renderTagsSurface(surface);
38262
38793
  }
38263
38794
  if (state.activeView === 'reflog') {
38264
- return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38795
+ return renderReflogSurface(surface);
38265
38796
  }
38266
38797
  if (state.activeView === 'bisect') {
38267
- return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
38798
+ return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
38268
38799
  }
38269
38800
  if (state.activeView === 'stash') {
38270
- return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38801
+ return renderStashSurface(surface);
38271
38802
  }
38272
38803
  if (state.activeView === 'worktrees') {
38273
- return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38804
+ return renderWorktreesSurface(surface);
38274
38805
  }
38275
38806
  if (state.activeView === 'submodules') {
38276
- return renderSubmodulesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38807
+ return renderSubmodulesSurface(surface);
38277
38808
  }
38278
38809
  if (state.activeView === 'pull-request') {
38279
- return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38810
+ return renderPullRequestSurface(surface);
38280
38811
  }
38281
38812
  if (state.activeView === 'pull-request-triage') {
38282
- return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38813
+ return renderPullRequestTriageSurface(surface);
38283
38814
  }
38284
38815
  if (state.activeView === 'issues') {
38285
- return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38816
+ return renderIssuesTriageSurface(surface);
38286
38817
  }
38287
38818
  if (state.activeView === 'conflicts') {
38288
- return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38819
+ return renderConflictsSurface(surface);
38289
38820
  }
38290
38821
  if (state.activeView === 'changelog') {
38291
- return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
38822
+ return renderChangelogSurface(surface);
38292
38823
  }
38293
- return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
38824
+ return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
38294
38825
  }
38295
38826
 
38296
38827
  /**
@@ -39163,21 +39694,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
39163
39694
  const bodyVisualLines = bodyHasContent
39164
39695
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
39165
39696
  : ['<empty>'];
39166
- const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, bodyTextWidth);
39167
- const summaryFirst = `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${summaryWrapped[0] || ''}`;
39168
- const summaryRest = summaryWrapped.slice(1).map((line) => ` ${line}`);
39169
- const headerLines = [
39170
- statusLine,
39171
- '',
39172
- summaryFirst,
39173
- ...summaryRest,
39174
- `${compose.field === 'body' && compose.editing ? '>' : ' '} Body:`,
39175
- ...bodyVisualLines.map((line, index) => {
39176
- const isLast = index === bodyVisualLines.length - 1;
39177
- return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
39178
- }),
39179
- '',
39180
- ];
39697
+ const hasSummary = Boolean(compose.summary);
39698
+ const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
39699
+ const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
39700
+ // The generated subject is the thing the user is looking for — render
39701
+ // it bold + accent so it pops out of the inspector instead of blending
39702
+ // into the dim label/body text. The `Summary:` label stays dim.
39703
+ const summaryLabel = `${summaryMarker} Summary: `;
39704
+ const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
39705
+ const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
39706
+ const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
39181
39707
  const trailerLines = [
39182
39708
  ...(compose.message ? ['', compose.message] : []),
39183
39709
  ...(compose.details || []).map((line) => ` ${line}`),
@@ -39191,10 +39717,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
39191
39717
  flexDirection: 'column',
39192
39718
  width,
39193
39719
  paddingX: 1,
39194
- }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
39195
- key: `commit-header-${index}`,
39196
- dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
39197
- }, truncateCells(line, width - 4))),
39720
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
39721
+ // Summary: dim label + the subject value emphasized so it's easy to spot.
39722
+ h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
39723
+ bold: hasSummary,
39724
+ color: summaryColor,
39725
+ dimColor: !hasSummary,
39726
+ }, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
39727
+ key: `commit-summary-rest-${index}`,
39728
+ bold: true,
39729
+ color: summaryColor,
39730
+ }, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
39731
+ key: 'commit-body-label',
39732
+ dimColor: !(compose.field === 'body' && compose.editing),
39733
+ }, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
39734
+ const isLast = index === bodyVisualLines.length - 1;
39735
+ return h(Text, {
39736
+ key: `commit-body-${index}`,
39737
+ dimColor: true,
39738
+ }, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
39739
+ }), h(Text, { key: 'commit-spacer-2' }, ''),
39198
39740
  // Loading indicator + commit result/details stay inline with the body
39199
39741
  // (they describe what just happened to the fields above). The action
39200
39742
  // hint ("e edit | c commit | I AI draft") moves to the bottom of the
@@ -39285,41 +39827,11 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
39285
39827
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
39286
39828
  * of #890. No behavior change.
39287
39829
  */
39288
- /**
39289
- * Rail-mode inspector — shown on terminals < 100 columns when the
39290
- * detail panel does not hold focus. The full inspector (commit body,
39291
- * file list, actions) does not survive truncation to ~4 content cells
39292
- * so we collapse to a stack with the panel label and the selected
39293
- * commit's shortHash. Focus pops the panel back to its expanded
39294
- * widths via the layout, so this renderer is only reached at rest.
39295
- *
39296
- * Help / overlay states are still handled by their own renderers
39297
- * above; this short-circuit only kicks in for the regular "view the
39298
- * commit" cases.
39299
- */
39300
- function renderInspectorRail(h, components, state, detail, width, theme, focused) {
39301
- const { Box, Text } = components;
39302
- // Prefer the loaded detail's hash (canonical) but fall back to the
39303
- // selected list row's shortHash so the rail isn't blank on the
39304
- // first render before getCommitDetail resolves.
39305
- const selectedRow = getSelectedInkCommit(state);
39306
- const hashText = detail?.hash.slice(0, 4)
39307
- ?? selectedRow?.shortHash.slice(0, 4)
39308
- ?? '····';
39309
- return h(Box, {
39310
- borderColor: focusBorderColor(theme, focused),
39311
- borderStyle: theme.borderStyle,
39312
- flexDirection: 'column',
39313
- width,
39314
- paddingX: 1,
39315
- }, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
39316
- }
39317
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
39830
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
39318
39831
  const focused = state.focus === 'detail';
39319
39832
  // Overlays (help / palette / input / confirmation / chord) take
39320
- // precedence over rail because they always claim the panel's width
39321
- // via the help-overlay layout branch — and railing those would
39322
- // defeat their whole purpose (the user is reading them).
39833
+ // precedence over every per-view surface because they claim the
39834
+ // panel's full width via the help-overlay layout branch.
39323
39835
  if (state.showHelp) {
39324
39836
  return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
39325
39837
  }
@@ -39351,15 +39863,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39351
39863
  if (state.pendingKey && !state.splitPlan) {
39352
39864
  return renderChordOverlay(h, components, state, width, theme, focused);
39353
39865
  }
39354
- // Rail mode applies only after every overlay above has had its say
39355
- // — those would all be unreadable at 4 cells of content. The layout
39356
- // also clears `railed` whenever the inspector takes focus, so we
39357
- // can safely short-circuit the per-view dispatch here without
39358
- // worrying about hiding the panel from a user who's actively
39359
- // reading it.
39360
- if (railed) {
39361
- return renderInspectorRail(h, components, state, detail, width, theme, focused);
39362
- }
39363
39866
  // The synthetic "(+) new commit" row routes the inspector through the
39364
39867
  // worktree summary so the user sees what's staged / unstaged at a glance
39365
39868
  // — same surface as the compose view's right panel.
@@ -39409,6 +39912,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39409
39912
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
39410
39913
  }
39411
39914
 
39915
+ /**
39916
+ * Runtime React Context for the workstation (#1136).
39917
+ *
39918
+ * The render layer currently drills `state` / `dispatch` / `theme` /
39919
+ * `layout` / `context` through every `render*Surface` signature, so
39920
+ * adding a feature repeatedly means threading one more value through
39921
+ * `app → mainPanel → render<View>Surface`. This Context is the single
39922
+ * place those five values live; surfaces read what they need from it
39923
+ * instead of receiving 10–15 positional props.
39924
+ *
39925
+ * Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
39926
+ * module-level `React.createContext(...)`: the workstation never
39927
+ * statically imports React. `ink` + `react` are ESM-only and loaded via
39928
+ * dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
39929
+ * codebase compiles without bundling them. The Context object must be
39930
+ * built from that same runtime React instance — the one that renders
39931
+ * the tree and the one a consumer's `useContext` reads from have to be
39932
+ * identical. There is exactly one React instance per process, so we
39933
+ * lazily create the Context on first use and cache it; `LogInkApp`'s
39934
+ * provider and (in later PRs) the surface consumers all share the one
39935
+ * identity.
39936
+ */
39937
+ let cachedContext = null;
39938
+ /**
39939
+ * Lazily create (and thereafter return) the process-wide
39940
+ * `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
39941
+ * same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
39942
+ * tests use the statically-imported `react`.
39943
+ */
39944
+ function getLogInkRuntimeContext(React) {
39945
+ if (!cachedContext) {
39946
+ cachedContext = React.createContext(null);
39947
+ cachedContext.displayName = 'LogInkRuntimeContext';
39948
+ }
39949
+ return cachedContext;
39950
+ }
39951
+
39412
39952
  /**
39413
39953
  * Resolve + scaffold the coco config files the workstation can open in
39414
39954
  * `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
@@ -39892,6 +40432,10 @@ function LogInkApp(deps) {
39892
40432
  const loadingMoreCommitsRef = React.useRef(false);
39893
40433
  const loadMoreRequestRef = React.useRef(0);
39894
40434
  const mountedRef = React.useRef(true);
40435
+ // Last dropped stash {hash, message}, captured before `drop-stash` runs
40436
+ // so `undo-drop-stash` can re-store it. The dropped commit survives in
40437
+ // the object DB until gc, so the hash is enough to bring it back.
40438
+ const lastDroppedStashRef = React.useRef(null);
39895
40439
  // P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
39896
40440
  // a grace window of empty statusMessage and then on a steady cadence, so
39897
40441
  // the footer surfaces a different hint every interval until the user does
@@ -41135,11 +41679,15 @@ function LogInkApp(deps) {
41135
41679
  dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
41136
41680
  return;
41137
41681
  }
41682
+ // Humanize provider errors (rate limit / auth / context / network)
41683
+ // into a short actionable line; success-but-no-draft keeps its
41684
+ // message as-is.
41685
+ const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
41138
41686
  dispatch({
41139
41687
  type: 'commitCompose',
41140
- action: { type: 'setResult', message: result.message, details: result.details },
41688
+ action: { type: 'setResult', message: composeMessage, details: result.details },
41141
41689
  });
41142
- dispatch({ type: 'setStatus', value: result.message });
41690
+ dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
41143
41691
  }
41144
41692
  catch (error) {
41145
41693
  // Audit finding #3: defensive recovery for unexpected throws
@@ -42110,8 +42658,20 @@ function LogInkApp(deps) {
42110
42658
  const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42111
42659
  if (!stash)
42112
42660
  return { ok: false, message: 'No stash selected' };
42661
+ // Remember the dropped commit so `u` can undo it.
42662
+ if (stash.hash)
42663
+ lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
42113
42664
  return dropStash(git, stash);
42114
42665
  },
42666
+ 'undo-drop-stash': async () => {
42667
+ const dropped = lastDroppedStashRef.current;
42668
+ if (!dropped)
42669
+ return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
42670
+ const result = await restoreStash(git, dropped.hash, dropped.message);
42671
+ if (result.ok)
42672
+ lastDroppedStashRef.current = null;
42673
+ return result;
42674
+ },
42115
42675
  'apply-stash': async () => {
42116
42676
  const all = context.stashes?.stashes || [];
42117
42677
  const visible = state.filter
@@ -42122,6 +42682,16 @@ function LogInkApp(deps) {
42122
42682
  return { ok: false, message: 'No stash selected' };
42123
42683
  return applyStash(git, stash);
42124
42684
  },
42685
+ 'apply-stash-index': async () => {
42686
+ const all = context.stashes?.stashes || [];
42687
+ const visible = state.filter
42688
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
42689
+ : all;
42690
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42691
+ if (!stash)
42692
+ return { ok: false, message: 'No stash selected' };
42693
+ return applyStashKeepIndex(git, stash);
42694
+ },
42125
42695
  'pop-stash': async () => {
42126
42696
  const all = context.stashes?.stashes || [];
42127
42697
  const visible = state.filter
@@ -42132,6 +42702,26 @@ function LogInkApp(deps) {
42132
42702
  return { ok: false, message: 'No stash selected' };
42133
42703
  return popStash(git, stash);
42134
42704
  },
42705
+ 'rename-stash': async () => {
42706
+ const all = context.stashes?.stashes || [];
42707
+ const visible = state.filter
42708
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
42709
+ : all;
42710
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42711
+ if (!stash)
42712
+ return { ok: false, message: 'No stash selected' };
42713
+ return renameStash(git, stash, payload ?? '');
42714
+ },
42715
+ 'stash-branch': async () => {
42716
+ const all = context.stashes?.stashes || [];
42717
+ const visible = state.filter
42718
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
42719
+ : all;
42720
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42721
+ if (!stash)
42722
+ return { ok: false, message: 'No stash selected' };
42723
+ return stashBranch(git, stash, payload ?? '');
42724
+ },
42135
42725
  'bisect-good': async () => {
42136
42726
  if (!context.bisect?.active)
42137
42727
  return { ok: false, message: 'No bisect in progress' };
@@ -42542,11 +43132,12 @@ function LogInkApp(deps) {
42542
43132
  return deleteRemoteTag(git, tag.name);
42543
43133
  },
42544
43134
  'create-stash': async () => {
42545
- const message = payload?.trim();
42546
- if (!message)
42547
- return { ok: false, message: 'Stash message required' };
42548
- return createStash(git, message);
43135
+ // Empty is allowed — createStash turns it into a quick WIP stash
43136
+ // (git's own `WIP on <branch>` subject). Naming is optional.
43137
+ return createStash(git, payload ?? '');
42549
43138
  },
43139
+ 'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
43140
+ 'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
42550
43141
  // #783 — full PR action panel handlers. Each wraps the matching
42551
43142
  // pullRequestActions verb. Strategy / body arrives via `payload`
42552
43143
  // — input prompts validate before they reach here, but the
@@ -42794,6 +43385,8 @@ function LogInkApp(deps) {
42794
43385
  const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
42795
43386
  return stageAllFiles(git, files);
42796
43387
  },
43388
+ 'stage-all': async () => stageAll(git),
43389
+ 'stage-pathspec': async () => stagePathspec(git, payload || ''),
42797
43390
  };
42798
43391
  const handler = handlers[id];
42799
43392
  if (!handler) {
@@ -42824,6 +43417,16 @@ function LogInkApp(deps) {
42824
43417
  'checkout-branch',
42825
43418
  'continue-operation',
42826
43419
  'pull-current-branch',
43420
+ // Fetch / pull / push bring in new commits and move
43421
+ // remote-tracking refs (origin/main, ahead/behind) — refresh the
43422
+ // graph so they appear instead of staying pinned to the pre-sync
43423
+ // state. (A successful push advances the local origin/<branch>
43424
+ // ref, so the chip should hop to the pushed commit.)
43425
+ 'fetch-remotes',
43426
+ 'fetch-selected-branch',
43427
+ 'pull-selected-branch',
43428
+ 'push-current-branch',
43429
+ 'push-selected-branch',
42827
43430
  'cherry-pick-commit',
42828
43431
  'revert-commit',
42829
43432
  'reset-hard-to-commit',
@@ -42878,6 +43481,11 @@ function LogInkApp(deps) {
42878
43481
  if (result?.ok && id === 'add-to-gitignore') {
42879
43482
  await refreshWorktreeContext();
42880
43483
  }
43484
+ // Stage-all / stage-pathspec change staged/unstaged counts — refresh
43485
+ // the worktree so the status list + compose summary reflect it.
43486
+ if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
43487
+ await refreshWorktreeContext();
43488
+ }
42881
43489
  if (result?.ok && id === 'drop-stash') {
42882
43490
  // Explicit worktree refresh in case the dropped stash carried
42883
43491
  // untracked-file state that's now collected.
@@ -43461,6 +44069,11 @@ function LogInkApp(deps) {
43461
44069
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
43462
44070
  : undefined;
43463
44071
  getLogInkInputEvents(state, inputValue, key, {
44072
+ // Narrow terminals show one pane at a time (#1135) — gates the `v`
44073
+ // peek key. Derived the same way the layout does, since `layout`
44074
+ // is computed later in the render path (not in this callback).
44075
+ singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
44076
+ LAYOUT_SINGLE_PANE_BELOW,
43464
44077
  detailFileCount: detail?.files.length,
43465
44078
  previewLineCount: diffPreviewLineCount,
43466
44079
  worktreeDiffLineCount: worktreeDiff?.lines.length,
@@ -43585,7 +44198,14 @@ function LogInkApp(deps) {
43585
44198
  exit();
43586
44199
  }
43587
44200
  else if (event.type === 'refreshContext') {
44201
+ // The user-initiated refresh (`r`) refreshes BOTH the metadata
44202
+ // context (branches/tags/worktree) AND the commit rows. Without
44203
+ // the row re-fetch the history graph stays pinned to whatever
44204
+ // commits existed at boot — new commits (made in another
44205
+ // terminal, or remote commits brought in by a fetch) never
44206
+ // appear until relaunch, which reads as "the history is stuck."
43588
44207
  void refreshContext();
44208
+ void refreshHistoryRows();
43589
44209
  }
43590
44210
  else if (event.type === 'toggleSelectedFileStage') {
43591
44211
  void toggleSelectedFileStage();
@@ -43684,6 +44304,24 @@ function LogInkApp(deps) {
43684
44304
  }
43685
44305
  });
43686
44306
  });
44307
+ // In single-pane mode (narrow terminals) only one pane renders, so an
44308
+ // active overlay must pull its own pane into view rather than stay
44309
+ // hidden behind whatever pane focus points at. The split-plan overlay
44310
+ // lives in the main panel; every other overlay (help / palette / theme
44311
+ // / gitignore / input prompt / confirmation / chord) renders in the
44312
+ // inspector. Ignored above the single-pane breakpoint (all panes show).
44313
+ const forcedPane = state.splitPlan
44314
+ ? 'main'
44315
+ : state.showHelp ||
44316
+ state.showCommandPalette ||
44317
+ state.showThemePicker ||
44318
+ state.gitignorePicker ||
44319
+ state.inputPrompt ||
44320
+ state.pendingConfirmationId ||
44321
+ state.pendingMutationConfirmation ||
44322
+ state.pendingKey
44323
+ ? 'inspector'
44324
+ : undefined;
43687
44325
  // Layout depends on focus (sidebar grows when focused), so it's
43688
44326
  // computed here — after state is in scope but before the render path.
43689
44327
  const layout = getLogInkLayout({
@@ -43692,7 +44330,22 @@ function LogInkApp(deps) {
43692
44330
  sidebarFocused: state.focus === 'sidebar',
43693
44331
  inspectorFocused: state.focus === 'detail',
43694
44332
  helpOverlayActive: state.showHelp,
44333
+ forcedPane,
43695
44334
  });
44335
+ // Runtime Context provider (#1136). Bundles the five most-drilled
44336
+ // values so surfaces can read them from context instead of receiving
44337
+ // them as positional props. No consumers yet — this PR only installs
44338
+ // the provider at the root; the surface families migrate in later PRs.
44339
+ // A Context.Provider renders its children transparently (no host
44340
+ // output), so wrapping the tree is behavior-preserving.
44341
+ const RuntimeContext = getLogInkRuntimeContext(React);
44342
+ const runtimeContextValue = {
44343
+ state,
44344
+ dispatch,
44345
+ theme,
44346
+ layout,
44347
+ context,
44348
+ };
43696
44349
  if (layout.tooSmall) {
43697
44350
  return h(Box, {
43698
44351
  flexDirection: 'column',
@@ -43706,7 +44359,35 @@ function LogInkApp(deps) {
43706
44359
  if (showOnboarding) {
43707
44360
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
43708
44361
  }
43709
- 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));
44362
+ // Panel renderers are thunks so single-pane mode can build only the
44363
+ // visible pane — the main-panel render in particular is expensive, so
44364
+ // we don't want to invoke the two hidden ones just to drop them.
44365
+ const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
44366
+ const mainSurface = {
44367
+ h,
44368
+ components: { Box, Text },
44369
+ state,
44370
+ context,
44371
+ contextStatus,
44372
+ bodyRows: layout.bodyRows,
44373
+ width: layout.mainPanelWidth,
44374
+ theme,
44375
+ };
44376
+ 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);
44377
+ const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
44378
+ // Single-pane mode (narrow terminals): exactly one full-width pane,
44379
+ // chosen by `layout.visiblePane`; Tab cycles which one. Above the
44380
+ // breakpoint all three tile side by side as before.
44381
+ const bodyPanels = layout.singlePane
44382
+ ? [
44383
+ layout.visiblePane === 'sidebar'
44384
+ ? sidebarPanel()
44385
+ : layout.visiblePane === 'inspector'
44386
+ ? detailPanel()
44387
+ : mainPanel(),
44388
+ ]
44389
+ : [sidebarPanel(), mainPanel(), detailPanel()];
44390
+ 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)));
43710
44391
  }
43711
44392
 
43712
44393
  /**
@@ -45826,6 +46507,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
45826
46507
  return { authenticated: true, counts };
45827
46508
  }
45828
46509
 
46510
+ /**
46511
+ * Clone a remote repository into a local path — the runtime side of the
46512
+ * workspace surface's `c` (clone) flow.
46513
+ *
46514
+ * `deriveRepoName` is pure (and tested) so the UI can pre-fill the
46515
+ * destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
46516
+ * does the filesystem-touching work and reports a friendly result.
46517
+ */
46518
+ /**
46519
+ * Infer the repository folder name from a clone URL or SSH spec:
46520
+ * git@github.com:gfargo/coco.git → coco
46521
+ * https://github.com/gfargo/coco → coco
46522
+ * https://example.com/a/b/c.git/ → c
46523
+ * Falls back to `repo` when nothing usable can be parsed.
46524
+ */
46525
+ function deriveRepoName(url) {
46526
+ const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
46527
+ if (!trimmed)
46528
+ return 'repo';
46529
+ // Split on both `/` and `:` so `host:owner/name` SSH specs work.
46530
+ const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
46531
+ return segment || 'repo';
46532
+ }
46533
+ /**
46534
+ * Clone `url` into `targetPath`. Refuses to clobber an existing path so
46535
+ * a typo never overwrites a directory. Network / auth failures surface
46536
+ * git's own message (trimmed to one line).
46537
+ */
46538
+ async function cloneRepo(url, targetPath) {
46539
+ const remote = url.trim();
46540
+ const dest = targetPath.trim();
46541
+ if (!remote)
46542
+ return { ok: false, message: 'Enter a remote URL to clone.' };
46543
+ if (!dest)
46544
+ return { ok: false, message: 'Enter a destination path.' };
46545
+ if (fs__namespace.existsSync(dest)) {
46546
+ return { ok: false, message: `${dest} already exists — choose another path.` };
46547
+ }
46548
+ try {
46549
+ await simpleGit.simpleGit().clone(remote, dest);
46550
+ return { ok: true, message: `Cloned into ${dest}` };
46551
+ }
46552
+ catch (error) {
46553
+ const raw = error instanceof Error ? error.message : String(error);
46554
+ return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
46555
+ }
46556
+ }
46557
+
45829
46558
  function resolveStoreDir(subdir) {
45830
46559
  const xdg = process.env.XDG_CACHE_HOME;
45831
46560
  const root = xdg && xdg.trim().length > 0 ? xdg : path__namespace$1.join(os__namespace$1.homedir(), '.cache');
@@ -46068,6 +46797,7 @@ function createWorkspaceState(init) {
46068
46797
  showThemePicker: false,
46069
46798
  themePickerFilter: '',
46070
46799
  themePickerIndex: 0,
46800
+ helpScrollOffset: 0,
46071
46801
  knownRepoPaths: init.knownRepoPaths ?? [],
46072
46802
  pullRequestFetching: [],
46073
46803
  };
@@ -46233,10 +46963,17 @@ function applyWorkspaceAction(state, action) {
46233
46963
  return { ...state, status: action.status };
46234
46964
  }
46235
46965
  case 'toggle-help': {
46236
- return { ...state, showHelp: !state.showHelp, showOnboarding: false };
46966
+ // Always reopen at the top picking up the last scroll position
46967
+ // is more surprising than predictable for a reference overlay.
46968
+ return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
46237
46969
  }
46238
46970
  case 'close-help': {
46239
- return { ...state, showHelp: false };
46971
+ return { ...state, showHelp: false, helpScrollOffset: 0 };
46972
+ }
46973
+ case 'scroll-help': {
46974
+ // Floor-clamp at 0 only; the renderer ceiling-clamps against the
46975
+ // real content height so `j` past the end sticks at the last row.
46976
+ return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
46240
46977
  }
46241
46978
  case 'toggle-theme-picker': {
46242
46979
  return {
@@ -46566,6 +47303,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
46566
47303
  const all = buildWorkspaceListRows(state, {
46567
47304
  width: options.width,
46568
47305
  spinnerTick: options.spinnerTick,
47306
+ now: options.now,
46569
47307
  });
46570
47308
  const visibleCount = Math.max(1, options.rows);
46571
47309
  if (all.length <= visibleCount) {
@@ -46689,10 +47427,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
46689
47427
  // The contextual slot drops bindings users can find via the help
46690
47428
  // overlay (arrow keys, tab); the global slot is the safety net so
46691
47429
  // `? help` and `q quit` never disappear.
46692
- const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
47430
+ const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
46693
47431
  const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
46694
47432
  const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
46695
47433
  const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
47434
+ const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
46696
47435
  const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
46697
47436
  const GLOBAL_HINTS = ['? help', 'q quit'];
46698
47437
  function contextualHintsFor(focus) {
@@ -46703,6 +47442,8 @@ function contextualHintsFor(focus) {
46703
47442
  return FILTER_CONTEXTUAL;
46704
47443
  case 'add-repo':
46705
47444
  return ADD_REPO_CONTEXTUAL;
47445
+ case 'clone-repo':
47446
+ return CLONE_REPO_CONTEXTUAL;
46706
47447
  case 'confirm-delete':
46707
47448
  return CONFIRM_DELETE_CONTEXTUAL;
46708
47449
  case 'list':
@@ -46717,6 +47458,7 @@ function buildWorkspaceFooter(state) {
46717
47458
  // is open and showing them would be misleading.
46718
47459
  const isModal = state.focus === 'filter' ||
46719
47460
  state.focus === 'add-repo' ||
47461
+ state.focus === 'clone-repo' ||
46720
47462
  state.focus === 'confirm-delete';
46721
47463
  const global = isModal ? [] : GLOBAL_HINTS;
46722
47464
  const allHints = [...contextual, ...global];
@@ -46778,6 +47520,7 @@ function buildWorkspaceHelpSections() {
46778
47520
  { glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
46779
47521
  { glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
46780
47522
  { glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
47523
+ { glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
46781
47524
  { glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
46782
47525
  ],
46783
47526
  },
@@ -46795,7 +47538,7 @@ function buildWorkspaceOnboarding(state) {
46795
47538
  : undefined,
46796
47539
  populatedHint: empty
46797
47540
  ? undefined
46798
- : 'Press `enter` to open a repo · `?` for the full keymap · `a` to add a repo by path.',
47541
+ : 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
46799
47542
  };
46800
47543
  }
46801
47544
 
@@ -47091,6 +47834,7 @@ function renderListBody(deps, width, height) {
47091
47834
  width,
47092
47835
  rows: listRows,
47093
47836
  spinnerTick: deps.spinnerTick,
47837
+ now: deps.now,
47094
47838
  });
47095
47839
  const visibleRepos = selectVisibleRepos(state);
47096
47840
  const filterChip = state.filter
@@ -47126,7 +47870,7 @@ function renderListBody(deps, width, height) {
47126
47870
  function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
47127
47871
  const { React, ink, theme } = deps;
47128
47872
  const { Box, Text } = ink;
47129
- 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));
47873
+ 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));
47130
47874
  }
47131
47875
  function renderHelpOverlay(deps) {
47132
47876
  if (!deps.state.showHelp) {
@@ -47139,34 +47883,68 @@ function renderHelpOverlay(deps) {
47139
47883
  // Columns: glyph cell (4 cells) · keys (padded to longest) · description.
47140
47884
  const glyphWidth = 4;
47141
47885
  const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
47142
- const children = [];
47143
- // Title bar accent-tinged, matches the chip-style header on the
47144
- // main surface so the help reads as the same app, just a different
47145
- // panel.
47146
- 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 ')));
47147
- children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
47148
- // Sections each gets a title in accent, optional subtitle dim,
47149
- // then its rows, then a blank line.
47886
+ // Body lines — every scrollable row below the pinned title. Built as
47887
+ // a flat list (section title optional subtitle rows → inter-section
47888
+ // spacer) so we can window it against the available height. Each entry
47889
+ // is `flexShrink: 0` so Ink never crushes rows on top of each other
47890
+ // when the keymap is taller than the panel (which used to collapse the
47891
+ // title and the first category onto the same line).
47892
+ const body = [];
47150
47893
  sections.forEach((section, sIndex) => {
47151
- children.push(React.createElement(Text, {
47894
+ body.push(React.createElement(Text, {
47152
47895
  key: `section-${sIndex}-title`,
47153
47896
  bold: true,
47154
47897
  color: theme.noColor ? undefined : theme.colors.muted,
47155
47898
  }, section.title.toUpperCase()));
47156
47899
  if (section.subtitle) {
47157
- children.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
47900
+ body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
47158
47901
  }
47159
47902
  section.rows.forEach((row, rIndex) => {
47160
- children.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
47903
+ body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
47161
47904
  });
47162
47905
  if (sIndex < sections.length - 1) {
47163
- children.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
47906
+ body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
47164
47907
  }
47165
47908
  });
47909
+ // Vertical budget: the overlay shares the column with the header
47910
+ // (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
47911
+ // (2), the pinned title (1) and the title/body separator (1). Whatever
47912
+ // is left is the window we slide the body through.
47913
+ const HEADER_ROWS = 3;
47914
+ const overlayChromeRows = 4;
47915
+ const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
47916
+ // Ceiling-clamp the offset here (the reducer only floors at 0) so
47917
+ // scrolling past the end sticks at the last row instead of revealing
47918
+ // blank space.
47919
+ const maxOffset = Math.max(0, body.length - visibleRows);
47920
+ const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
47921
+ const children = [];
47922
+ // Title bar — accent-tinged, matches the chip-style header on the
47923
+ // main surface so the help reads as the same app, just a different
47924
+ // panel. Pinned above the scrolling body.
47925
+ 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 ')));
47926
+ children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
47927
+ // "more above" / "more below" hints each consume a window row so they
47928
+ // don't push body content off-screen. Mirrors the `coco ui` overlay.
47929
+ let windowSize = visibleRows;
47930
+ const hasMoreAbove = offset > 0;
47931
+ if (hasMoreAbove) {
47932
+ windowSize -= 1;
47933
+ children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
47934
+ }
47935
+ const hasMoreBelow = offset + windowSize < body.length;
47936
+ if (hasMoreBelow) {
47937
+ windowSize -= 1;
47938
+ }
47939
+ children.push(...body.slice(offset, offset + windowSize));
47940
+ if (hasMoreBelow) {
47941
+ children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
47942
+ }
47166
47943
  return React.createElement(Box, {
47167
47944
  borderColor: focusBorderColor(theme, true),
47168
47945
  borderStyle: theme.borderStyle,
47169
47946
  flexDirection: 'column',
47947
+ flexShrink: 0,
47170
47948
  paddingX: 1,
47171
47949
  }, ...children);
47172
47950
  }
@@ -47217,6 +47995,29 @@ function renderAddRepoPrompt(deps) {
47217
47995
  ? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
47218
47996
  : null);
47219
47997
  }
47998
+ function renderCloneRepoPrompt(deps) {
47999
+ if (deps.state.focus !== 'clone-repo') {
48000
+ return null;
48001
+ }
48002
+ const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
48003
+ const { Box, Text } = ink;
48004
+ const urlActive = cloneField === 'url' && !cloning;
48005
+ const targetActive = cloneField === 'target' && !cloning;
48006
+ const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
48007
+ const hint = cloning
48008
+ ? 'Cloning… this can take a moment for large repos.'
48009
+ : cloneField === 'url'
48010
+ ? 'Paste a remote URL (https or git@…), then enter for the destination.'
48011
+ : 'Edit the destination · tab to complete · enter to clone · esc to cancel';
48012
+ return React.createElement(Box, {
48013
+ borderColor: focusBorderColor(theme, true),
48014
+ borderStyle: theme.borderStyle,
48015
+ flexDirection: 'column',
48016
+ paddingX: 1,
48017
+ }, 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
48018
+ ? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
48019
+ : null);
48020
+ }
47220
48021
  const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
47221
48022
  function renderFooter(deps) {
47222
48023
  const { React, ink, state, theme } = deps;
@@ -47258,8 +48059,10 @@ function computeBodyHeight(deps) {
47258
48059
  const FOOTER_ROWS = FOOTER_HEIGHT;
47259
48060
  const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
47260
48061
  const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
48062
+ // Clone modal is one row taller (URL + Into + hint + completion).
48063
+ const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
47261
48064
  const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
47262
- const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
48065
+ const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
47263
48066
  return Math.max(8, deps.rows - reserved);
47264
48067
  }
47265
48068
  function renderWorkspaceApp(deps) {
@@ -47281,7 +48084,7 @@ function renderWorkspaceApp(deps) {
47281
48084
  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));
47282
48085
  }
47283
48086
  const bodyHeight = computeBodyHeight(deps);
47284
- 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));
48087
+ 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));
47285
48088
  }
47286
48089
 
47287
48090
  /**
@@ -47318,6 +48121,21 @@ function resolveWorkspaceInput(input, key, state) {
47318
48121
  if (key.escape || input === '?' || input === 'q') {
47319
48122
  return { kind: 'action', action: { type: 'close-help' } };
47320
48123
  }
48124
+ // The keymap is taller than the panel on short terminals — let
48125
+ // j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
48126
+ // `coco ui` help overlay.
48127
+ if (key.downArrow || input === 'j') {
48128
+ return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
48129
+ }
48130
+ if (key.upArrow || input === 'k') {
48131
+ return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
48132
+ }
48133
+ if (key.ctrl && input === 'd') {
48134
+ return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
48135
+ }
48136
+ if (key.ctrl && input === 'u') {
48137
+ return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
48138
+ }
47321
48139
  return { kind: 'noop' };
47322
48140
  }
47323
48141
  // Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
@@ -47368,6 +48186,14 @@ function resolveWorkspaceInput(input, key, state) {
47368
48186
  // can drive the path-completion prompt.
47369
48187
  return { kind: 'noop' };
47370
48188
  }
48189
+ if (state.focus === 'clone-repo') {
48190
+ if (key.escape) {
48191
+ return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
48192
+ }
48193
+ // Enter/Tab/printable keys drive the URL + destination prompt in the
48194
+ // runtime (it owns the two-field state + path completion).
48195
+ return { kind: 'noop' };
48196
+ }
47371
48197
  // Confirm-delete is modal: only `y` confirms, anything else cancels.
47372
48198
  if (state.focus === 'confirm-delete') {
47373
48199
  if (input === 'y' || input === 'Y') {
@@ -47462,6 +48288,9 @@ function resolveWorkspaceInput(input, key, state) {
47462
48288
  if (input === 'a') {
47463
48289
  return { kind: 'add-repo' };
47464
48290
  }
48291
+ if (input === 'c') {
48292
+ return { kind: 'clone-repo' };
48293
+ }
47465
48294
  if (input === 'd') {
47466
48295
  return { kind: 'request-delete' };
47467
48296
  }
@@ -48016,6 +48845,19 @@ function WorkspaceInkApp(props) {
48016
48845
  const [filterDraft, setFilterDraft] = React.useState('');
48017
48846
  const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
48018
48847
  const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
48848
+ // Clone-repo modal (`c`). Two fields: the remote URL and the
48849
+ // destination path. `cloneField` tracks which is active; `cloneTarget`
48850
+ // auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
48851
+ // (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
48852
+ // `git clone` runs. The boot cwd is captured once at mount so it stays
48853
+ // the directory the workspace launched in even after drill-in.
48854
+ const bootCwdRef = React.useRef(process.cwd());
48855
+ const [cloneUrl, setCloneUrl] = React.useState('');
48856
+ const [cloneTarget, setCloneTarget] = React.useState('');
48857
+ const [cloneField, setCloneField] = React.useState('url');
48858
+ const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
48859
+ const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
48860
+ const [cloning, setCloning] = React.useState(false);
48019
48861
  // Tick counter for the per-row PR-fetch spinner. Bumped on a
48020
48862
  // setInterval that only runs while at least one row is mid-fetch
48021
48863
  // (see effect below) so idle workspaces don't burn CPU on animation
@@ -48065,6 +48907,18 @@ function WorkspaceInkApp(props) {
48065
48907
  addRepoDraftRef.current = addRepoDraft;
48066
48908
  const addRepoCompletionRef = React.useRef(addRepoCompletion);
48067
48909
  addRepoCompletionRef.current = addRepoCompletion;
48910
+ const cloneUrlRef = React.useRef(cloneUrl);
48911
+ cloneUrlRef.current = cloneUrl;
48912
+ const cloneTargetRef = React.useRef(cloneTarget);
48913
+ cloneTargetRef.current = cloneTarget;
48914
+ const cloneFieldRef = React.useRef(cloneField);
48915
+ cloneFieldRef.current = cloneField;
48916
+ const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
48917
+ cloneTargetEditedRef.current = cloneTargetEdited;
48918
+ const cloneCompletionRef = React.useRef(cloneCompletion);
48919
+ cloneCompletionRef.current = cloneCompletion;
48920
+ const cloningRef = React.useRef(cloning);
48921
+ cloningRef.current = cloning;
48068
48922
  // Background discovery + PR-count refresh on mount.
48069
48923
  React.useEffect(() => {
48070
48924
  let cancelled = false;
@@ -48297,6 +49151,60 @@ function WorkspaceInkApp(props) {
48297
49151
  });
48298
49152
  }
48299
49153
  }, [addRepoDraft, dispatch, props]);
49154
+ // Default destination for a clone URL: `<bootCwd>/<repo-name>`.
49155
+ const cloneTargetFor = React.useCallback((url) => {
49156
+ return path__namespace$1.join(bootCwdRef.current, deriveRepoName(url));
49157
+ }, []);
49158
+ const openClone = React.useCallback(() => {
49159
+ setCloneUrl('');
49160
+ setCloneTarget('');
49161
+ setCloneField('url');
49162
+ setCloneTargetEdited(false);
49163
+ setCloneCompletion(completePath(`${bootCwdRef.current}/`));
49164
+ dispatch({ type: 'set-focus', focus: 'clone-repo' });
49165
+ }, [dispatch]);
49166
+ const commitClone = React.useCallback(async () => {
49167
+ const url = cloneUrlRef.current.trim();
49168
+ const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
49169
+ if (!url) {
49170
+ dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
49171
+ return;
49172
+ }
49173
+ if (!target) {
49174
+ dispatch({ type: 'set-status', status: 'Enter a destination path.' });
49175
+ return;
49176
+ }
49177
+ setCloning(true);
49178
+ dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
49179
+ const result = await cloneRepo(url, target);
49180
+ if (unmountedRef.current)
49181
+ return;
49182
+ setCloning(false);
49183
+ if (!result.ok) {
49184
+ // Keep the modal open so the user can fix the URL / path and retry.
49185
+ dispatch({ type: 'set-status', status: result.message });
49186
+ return;
49187
+ }
49188
+ const updated = appendKnownRepo(target);
49189
+ dispatch({ type: 'replace-known-repos', paths: updated });
49190
+ dispatch({ type: 'set-focus', focus: 'list' });
49191
+ dispatch({ type: 'set-status', status: result.message });
49192
+ dispatch({ type: 'set-loading', loading: true });
49193
+ try {
49194
+ const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
49195
+ const overview = await props.loadOverview(props.roots, merged);
49196
+ writeCachedWorkspace(props.roots, overview);
49197
+ dispatch({ type: 'replace-overview', overview });
49198
+ dispatch({ type: 'anchor-cursor-by-path', path: target });
49199
+ }
49200
+ catch (err) {
49201
+ dispatch({ type: 'set-loading', loading: false });
49202
+ dispatch({
49203
+ type: 'set-status',
49204
+ status: err instanceof Error ? err.message : 'Refresh failed.',
49205
+ });
49206
+ }
49207
+ }, [dispatch, props]);
48300
49208
  // Callback refs so the stable input handler can reach the latest
48301
49209
  // closure without taking them in deps.
48302
49210
  const commitAddRepoRef = React.useRef(commitAddRepo);
@@ -48309,6 +49217,10 @@ function WorkspaceInkApp(props) {
48309
49217
  refreshRowRef.current = refreshRow;
48310
49218
  const openAddRepoRef = React.useRef(openAddRepo);
48311
49219
  openAddRepoRef.current = openAddRepo;
49220
+ const openCloneRef = React.useRef(openClone);
49221
+ openCloneRef.current = openClone;
49222
+ const commitCloneRef = React.useRef(commitClone);
49223
+ commitCloneRef.current = commitClone;
48312
49224
  const requestDeleteRef = React.useRef(requestDelete);
48313
49225
  requestDeleteRef.current = requestDelete;
48314
49226
  const confirmDeleteRef = React.useRef(confirmDelete);
@@ -48391,6 +49303,73 @@ function WorkspaceInkApp(props) {
48391
49303
  }
48392
49304
  return;
48393
49305
  }
49306
+ if (state.focus === 'clone-repo') {
49307
+ // While the clone is running, swallow everything except Esc
49308
+ // (which is a no-op here — the clone is already in flight).
49309
+ if (cloningRef.current)
49310
+ return;
49311
+ if (key.escape) {
49312
+ dispatch({ type: 'set-focus', focus: 'list' });
49313
+ return;
49314
+ }
49315
+ const field = cloneFieldRef.current;
49316
+ const url = cloneUrlRef.current;
49317
+ const target = cloneTargetRef.current;
49318
+ const targetEdited = cloneTargetEditedRef.current;
49319
+ if (key.return) {
49320
+ if (field === 'url') {
49321
+ if (!url.trim()) {
49322
+ dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
49323
+ return;
49324
+ }
49325
+ // Advance to the (pre-filled, editable) destination field.
49326
+ const derived = targetEdited ? target : cloneTargetFor(url);
49327
+ setCloneTarget(derived);
49328
+ setCloneCompletion(completePath(derived));
49329
+ setCloneField('target');
49330
+ return;
49331
+ }
49332
+ void commitCloneRef.current();
49333
+ return;
49334
+ }
49335
+ if (key.tab && field === 'target') {
49336
+ const next = applyTabCompletion(target, cloneCompletionRef.current);
49337
+ setCloneTarget(next);
49338
+ setCloneTargetEdited(true);
49339
+ setCloneCompletion(completePath(next));
49340
+ return;
49341
+ }
49342
+ if (key.backspace || key.delete) {
49343
+ if (field === 'url') {
49344
+ const next = url.slice(0, -1);
49345
+ setCloneUrl(next);
49346
+ if (!targetEdited)
49347
+ setCloneTarget(next ? cloneTargetFor(next) : '');
49348
+ }
49349
+ else {
49350
+ const next = target.slice(0, -1);
49351
+ setCloneTarget(next);
49352
+ setCloneTargetEdited(true);
49353
+ setCloneCompletion(completePath(next || '~/'));
49354
+ }
49355
+ return;
49356
+ }
49357
+ if (rawInput && !key.ctrl && !key.meta) {
49358
+ if (field === 'url') {
49359
+ const next = url + rawInput;
49360
+ setCloneUrl(next);
49361
+ if (!targetEdited)
49362
+ setCloneTarget(cloneTargetFor(next));
49363
+ }
49364
+ else {
49365
+ const next = target + rawInput;
49366
+ setCloneTarget(next);
49367
+ setCloneTargetEdited(true);
49368
+ setCloneCompletion(completePath(next));
49369
+ }
49370
+ }
49371
+ return;
49372
+ }
48394
49373
  // Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
48395
49374
  // Handled here (rather than in the pure resolver) because the
48396
49375
  // resolver doesn't have a notion of "raw key with ctrl flag" for
@@ -48431,6 +49410,9 @@ function WorkspaceInkApp(props) {
48431
49410
  case 'add-repo':
48432
49411
  openAddRepoRef.current();
48433
49412
  break;
49413
+ case 'clone-repo':
49414
+ openCloneRef.current();
49415
+ break;
48434
49416
  case 'request-delete':
48435
49417
  requestDeleteRef.current();
48436
49418
  break;
@@ -48480,6 +49462,11 @@ function WorkspaceInkApp(props) {
48480
49462
  filterDraft,
48481
49463
  addRepoDraft,
48482
49464
  addRepoCompletion,
49465
+ cloneUrl,
49466
+ cloneTarget,
49467
+ cloneField,
49468
+ cloneCompletion,
49469
+ cloning,
48483
49470
  columns,
48484
49471
  rows,
48485
49472
  spinnerTick,