git-coco 0.51.0 → 0.52.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.51.0";
81
+ const BUILD_VERSION = "0.52.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -8229,9 +8229,40 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8229
8229
  return [];
8230
8230
  }
8231
8231
 
8232
+ async function refExists(git, ref) {
8233
+ try {
8234
+ // `--verify --quiet` suppresses stderr noise on a missing ref and
8235
+ // emits the resolved sha on stdout when it exists. simple-git
8236
+ // returns an empty string (rather than throwing) when git exits
8237
+ // 1 under `--quiet`, so the presence/absence check is on the
8238
+ // output, not on whether the call rejected.
8239
+ const out = await git.raw(['rev-parse', '--verify', '--quiet', ref]);
8240
+ return out.trim().length > 0;
8241
+ }
8242
+ catch {
8243
+ return false;
8244
+ }
8245
+ }
8232
8246
  /**
8233
8247
  * Retrieves the commit log for the current branch.
8234
8248
  *
8249
+ * Edge states that are not errors and should not be reported as such:
8250
+ *
8251
+ * - Detached HEAD (including mid-rebase and mid-bisect, which both
8252
+ * leave HEAD detached). There is no "current branch" to compare
8253
+ * against; the helper logs a yellow status line and returns [].
8254
+ * - Comparison ref missing — e.g. the repo has no `origin` remote,
8255
+ * so `origin/main` does not resolve; or the local comparison
8256
+ * branch (`main`) simply does not exist. Previously this threw
8257
+ * and surfaced as a red "Encountered an error" banner. Now we
8258
+ * probe the ref up front and report a clean status line.
8259
+ * - Empty rev-list output. The previous yellow "Unable to determine
8260
+ * first and last commit" wording read like an error; it's just
8261
+ * "no commits ahead of the comparison ref", which is the normal
8262
+ * outcome when the branch is at or behind its baseline.
8263
+ *
8264
+ * The catch block is reserved for genuinely unexpected git failures.
8265
+ *
8235
8266
  * @param {Object} options - The options for retrieving the commit log.
8236
8267
  * @param {SimpleGit} options.git - The SimpleGit instance.
8237
8268
  * @param {Logger} options.logger - The logger for logging messages.
@@ -8240,43 +8271,53 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8240
8271
  * @returns {Promise<CommitDetails[]>} The array of commit messages in the commit log.
8241
8272
  */
8242
8273
  async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main', comparisonRemote = 'origin', }) {
8274
+ const branchName = await getCurrentBranchName({ git });
8275
+ // Detached HEAD: `git rev-parse --abbrev-ref HEAD` returns the literal
8276
+ // string 'HEAD' in this state. Also covers mid-rebase and mid-bisect,
8277
+ // which both detach HEAD onto the picked / midpoint commit. There's
8278
+ // no branch to compare against, so don't pretend there was an error.
8279
+ if (!branchName || branchName === 'HEAD') {
8280
+ logger?.log('HEAD is detached (or a rebase / bisect is in progress) — no branch context to compare against.', { color: 'yellow' });
8281
+ return [];
8282
+ }
8243
8283
  try {
8244
- const branchName = await getCurrentBranchName({ git });
8245
- const hasCommits = (await git.raw(['rev-list', '--count', branchName])) !== '0';
8246
- if (!hasCommits) {
8247
- logger?.log('No commits on the current branch.');
8248
- return [];
8249
- }
8250
- let uniqueCommits;
8284
+ let comparisonRef;
8251
8285
  if (comparisonBranch === branchName) {
8252
- // If the comparison branch is the same as the current branch, we compare against the remote.
8253
- uniqueCommits = (await git.raw(['rev-list', `${comparisonRemote}/${comparisonBranch}..${branchName}`]))
8254
- .split('\n')
8255
- .filter(Boolean)
8256
- .reverse();
8286
+ // Same branch as the comparison target compare against the
8287
+ // remote-tracking ref. If the remote (or the ref) does not
8288
+ // exist, fall back to a clean status line rather than throwing.
8289
+ const remoteRef = `${comparisonRemote}/${comparisonBranch}`;
8290
+ if (!(await refExists(git, remoteRef))) {
8291
+ logger?.log(`No "${remoteRef}" ref to compare against — skipping changelog for "${branchName}".`, { color: 'yellow' });
8292
+ return [];
8293
+ }
8294
+ comparisonRef = remoteRef;
8257
8295
  }
8258
8296
  else {
8259
- // Your existing code for different branches
8260
- uniqueCommits = (await git.raw(['rev-list', `${comparisonBranch}..${branchName}`]))
8261
- .split('\n')
8262
- .filter(Boolean)
8263
- .reverse();
8297
+ if (!(await refExists(git, comparisonBranch))) {
8298
+ logger?.log(`Comparison branch "${comparisonBranch}" does not exist — skipping changelog for "${branchName}".`, { color: 'yellow' });
8299
+ return [];
8300
+ }
8301
+ comparisonRef = comparisonBranch;
8264
8302
  }
8265
- logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}"`, {
8266
- color: 'blue',
8267
- });
8303
+ const uniqueCommits = (await git.raw(['rev-list', `${comparisonRef}..${branchName}`]))
8304
+ .split('\n')
8305
+ .filter(Boolean)
8306
+ .reverse();
8307
+ logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}" vs "${comparisonRef}"`, { color: 'blue' });
8268
8308
  const firstCommit = uniqueCommits[0];
8269
8309
  const lastCommit = uniqueCommits[uniqueCommits.length - 1];
8270
8310
  if (!firstCommit || !lastCommit) {
8271
- logger?.log('Unable to determine first and last commit on the current branch', {
8272
- color: 'yellow',
8273
- });
8311
+ // Empty rev-list output is the normal outcome when the branch is
8312
+ // at or behind its baseline. Not an error.
8313
+ logger?.log(`No commits on "${branchName}" ahead of "${comparisonRef}".`);
8274
8314
  return [];
8275
8315
  }
8276
8316
  return await getCommitLogRangeDetails(firstCommit, lastCommit, { git, noMerges: true });
8277
8317
  }
8278
8318
  catch (error) {
8279
8319
  logger?.log('Encountered an error getting commit log from current branch', { color: 'red' });
8320
+ logger?.verbose(error instanceof Error ? error.message : String(error), { color: 'red' });
8280
8321
  }
8281
8322
  return [];
8282
8323
  }
@@ -14344,7 +14385,13 @@ const handler$9 = async (argv, logger) => {
14344
14385
  return `## Diff for ${data.branch}\n\n${diffSummary}`;
14345
14386
  }
14346
14387
  if (!data.commits || data.commits.length === 0) {
14347
- return `## ${data.branch}\n\nNo commits found.`;
14388
+ // Short-circuit with an empty context so the review loop drops
14389
+ // into `noResult` instead of spending an LLM call summarising
14390
+ // "No commits found." into a fake changelog entry. The
14391
+ // upstream helper (getCommitLogCurrentBranch) already logged
14392
+ // the reason (detached HEAD, missing comparison ref, branch at
14393
+ // baseline, etc.) in a friendly status line.
14394
+ return '';
14348
14395
  }
14349
14396
  let result = `## ${data.branch}\n\n`;
14350
14397
  result += data.commits.map(commit => {
@@ -14421,10 +14468,15 @@ const handler$9 = async (argv, logger) => {
14421
14468
  },
14422
14469
  noResult: async () => {
14423
14470
  if (config.range) {
14424
- logger.log(`No commits found in the provided range.`, { color: 'red' });
14471
+ logger.log(`No commits found in the provided range.`, { color: 'yellow' });
14425
14472
  commandExit(0);
14426
14473
  }
14427
- logger.log(`No commits found in the current branch.`, { color: 'red' });
14474
+ // Yellow rather than red — for the no-commits-on-current-branch
14475
+ // case the upstream helper has already explained the reason in
14476
+ // a friendly status line (detached HEAD, no comparison ref,
14477
+ // branch at baseline). This is the trailing summary, not an
14478
+ // error.
14479
+ logger.log(`No commits found in the current branch.`, { color: 'yellow' });
14428
14480
  commandExit(0);
14429
14481
  },
14430
14482
  });
@@ -17909,6 +17961,38 @@ const builder$4 = (yargs) => {
17909
17961
  return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
17910
17962
  };
17911
17963
 
17964
+ /**
17965
+ * Detect whether the repository has any commits yet.
17966
+ *
17967
+ * A "fresh" repo (one created by `git init` with no commits) has an
17968
+ * **unborn HEAD** — the `main` (or configured-default) branch ref
17969
+ * exists symbolically but doesn't point at any object. Any plumbing
17970
+ * command that tries to resolve HEAD (`git log`, `git show`, `git
17971
+ * rev-list`) fails fatally on such a repo with `fatal: your current
17972
+ * branch '<X>' does not have any commits yet`.
17973
+ *
17974
+ * Without an explicit pre-check, callers crash with that raw error
17975
+ * (see {@link ../utils/commandExecutor} — the generic-error path
17976
+ * just prints whatever was thrown). This helper lets a command
17977
+ * short-circuit to a friendly "no commits yet" message instead.
17978
+ *
17979
+ * Implementation uses `git rev-parse --verify HEAD` because it's the
17980
+ * cheapest "does HEAD resolve?" probe — no log walk, no working-tree
17981
+ * scan. Returns `true` when rev-parse rejects (unborn HEAD) and
17982
+ * `false` when it succeeds.
17983
+ *
17984
+ * @returns `true` when HEAD is unborn (no commits); `false` otherwise.
17985
+ */
17986
+ async function isEmptyRepo(git) {
17987
+ try {
17988
+ await git.revparse(['--verify', 'HEAD']);
17989
+ return false;
17990
+ }
17991
+ catch {
17992
+ return true;
17993
+ }
17994
+ }
17995
+
17912
17996
  /**
17913
17997
  * Git LFS pointer parsing + diff summarization (#884).
17914
17998
  *
@@ -18327,6 +18411,19 @@ function buildToggleGraphArgs(argv, fullGraph) {
18327
18411
  return { ...argv, view: argv.view ?? 'compact' };
18328
18412
  }
18329
18413
  async function getLogRows(git, argv, options = {}) {
18414
+ // Unborn HEAD short-circuit. Without this, `git log` on a freshly
18415
+ // `git init`'d repo throws "fatal: your current branch 'main' does
18416
+ // not have any commits yet" — fine when the caller can catch and
18417
+ // translate, painful otherwise (the workstation runtime surfaces it
18418
+ // as "Failed to load commits: fatal: ..." in the status line).
18419
+ //
18420
+ // Returning [] is the natural contract: callers that already render
18421
+ // an empty-history surface (`formatLogInkHistoryEmpty`) get the
18422
+ // right experience automatically; `coco log` retains its own
18423
+ // friendlier message via the handler's isEmptyRepo check.
18424
+ if (await isEmptyRepo(git)) {
18425
+ return [];
18426
+ }
18330
18427
  return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
18331
18428
  }
18332
18429
  async function getCommitDetail(git, commit) {
@@ -18398,6 +18495,7 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18398
18495
  deletions: file.deletions,
18399
18496
  },
18400
18497
  hunks: finalHunks,
18498
+ submoduleChange,
18401
18499
  };
18402
18500
  }
18403
18501
 
@@ -21810,6 +21908,56 @@ function formatLogInkBreadcrumb(viewStack) {
21810
21908
  // they're nested deeper than the root view.
21811
21909
  return `${viewStack.join(' › ')} ← <`;
21812
21910
  }
21911
+ /**
21912
+ * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
21913
+ * for the chrome header. Returns an empty string for a root-only stack
21914
+ * so the header stays compact when nothing has been pushed.
21915
+ *
21916
+ * The trailing `← esc` reminds the user that Esc is the way out — same
21917
+ * shape as the view breadcrumb's `← <` so the two read consistently.
21918
+ * The repo breadcrumb shows in addition to the view breadcrumb when
21919
+ * both stacks are non-trivial; the chrome layer is responsible for
21920
+ * laying them out side by side.
21921
+ *
21922
+ * Examples:
21923
+ * `[root]` → ''
21924
+ * `[coco, vendor/lib]` → 'coco › vendor/lib ← esc'
21925
+ * `[coco, vendor/lib, deep]` → 'coco › vendor/lib › deep ← esc'
21926
+ */
21927
+ function formatLogInkRepoBreadcrumb(repoStack) {
21928
+ if (repoStack.length <= 1) {
21929
+ return '';
21930
+ }
21931
+ return `${repoStack.map((frame) => frame.label).join(' › ')} ← esc`;
21932
+ }
21933
+ /**
21934
+ * Combine the repo-stack and view-stack breadcrumb segments for the
21935
+ * header chrome (#931). Each segment is independently rendered by its
21936
+ * formatter and may be empty; this helper interleaves the leading
21937
+ * spacing so the header builder doesn't have to branch on four cases.
21938
+ *
21939
+ * repoCrumb='' viewCrumb='' → ''
21940
+ * repoCrumb='X' viewCrumb='' → ' X'
21941
+ * repoCrumb='' viewCrumb='Y' → ' Y'
21942
+ * repoCrumb='X' viewCrumb='Y' → ' X Y'
21943
+ *
21944
+ * Two leading spaces match the existing chrome — they separate the
21945
+ * breadcrumb from the trailing repo/branch segment in the title row.
21946
+ * Four spaces between segments give the repo crumb visual breathing
21947
+ * room before the view crumb begins.
21948
+ */
21949
+ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
21950
+ if (repoCrumb && viewCrumb) {
21951
+ return ` ${repoCrumb} ${viewCrumb}`;
21952
+ }
21953
+ if (repoCrumb) {
21954
+ return ` ${repoCrumb}`;
21955
+ }
21956
+ if (viewCrumb) {
21957
+ return ` ${viewCrumb}`;
21958
+ }
21959
+ return '';
21960
+ }
21813
21961
  function getLogInkFooterHints(options) {
21814
21962
  if (options.pendingKey) {
21815
21963
  const continuations = getLogInkChordContinuations(options.pendingKey);
@@ -22564,6 +22712,93 @@ function withPoppedView(state) {
22564
22712
  pendingKey: undefined,
22565
22713
  };
22566
22714
  }
22715
+ /**
22716
+ * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
22717
+ * the active view position into the new frame's `parentReturn` so a
22718
+ * subsequent pop lands the user back where they came from, then
22719
+ * resets the per-frame navigation state (active view, view stack,
22720
+ * row / file / submodule cursors, filter) so the nested frame opens
22721
+ * in a clean slate — the mental equivalent of a fresh `coco ui`
22722
+ * launched against the submodule's working dir.
22723
+ *
22724
+ * Carry-over preferences (sidebar tab, branch / tag sort, palette
22725
+ * recents, inspector tab, diff view mode) are intentionally left
22726
+ * untouched. They're user-level choices that should persist across
22727
+ * frames, the same way they persist across view pushes today.
22728
+ *
22729
+ * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22730
+ * outside the reducer in `app.ts`'s parallel ref structure — this
22731
+ * helper only manages the pure view-model side of the push.
22732
+ */
22733
+ function withPushedRepoFrame(state, payload) {
22734
+ const newFrame = {
22735
+ label: payload.label,
22736
+ workdir: payload.workdir,
22737
+ entryRange: payload.entryRange,
22738
+ parentReturn: {
22739
+ activeView: state.activeView,
22740
+ selectedIndex: state.selectedIndex,
22741
+ selectedFileIndex: state.selectedFileIndex,
22742
+ selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22743
+ filter: state.filter,
22744
+ },
22745
+ };
22746
+ return {
22747
+ ...state,
22748
+ repoStack: [...state.repoStack, newFrame],
22749
+ activeView: 'history',
22750
+ viewStack: ['history'],
22751
+ selectedIndex: 0,
22752
+ selectedFileIndex: 0,
22753
+ selectedSubmoduleIndex: 0,
22754
+ filter: '',
22755
+ filterMode: false,
22756
+ pendingCommitFocused: false,
22757
+ pendingKey: undefined,
22758
+ pendingConfirmationId: undefined,
22759
+ pendingConfirmationPayload: undefined,
22760
+ pendingMutationConfirmation: undefined,
22761
+ };
22762
+ }
22763
+ /**
22764
+ * Pop the top repo frame off `state.repoStack` (#931) and restore
22765
+ * the parent's view position from the captured `parentReturn`. A
22766
+ * no-op when the stack is already at its single root frame so this
22767
+ * action is safe to dispatch from generic input handlers (e.g. the
22768
+ * Esc auto-pop wiring that lands in a follow-up PR).
22769
+ *
22770
+ * The defensive `parentReturn` fallback handles the never-supposed-
22771
+ * to-happen case where a non-root frame somehow has no return state
22772
+ * recorded — drop the frame but leave the user's view position
22773
+ * alone rather than crash mid-session.
22774
+ */
22775
+ function withPoppedRepoFrame(state) {
22776
+ if (state.repoStack.length <= 1) {
22777
+ return { ...state, pendingKey: undefined };
22778
+ }
22779
+ const topFrame = state.repoStack[state.repoStack.length - 1];
22780
+ const ret = topFrame.parentReturn;
22781
+ const repoStack = state.repoStack.slice(0, -1);
22782
+ if (!ret) {
22783
+ return { ...state, repoStack, pendingKey: undefined };
22784
+ }
22785
+ return {
22786
+ ...state,
22787
+ repoStack,
22788
+ activeView: ret.activeView,
22789
+ viewStack: [ret.activeView],
22790
+ selectedIndex: ret.selectedIndex,
22791
+ selectedFileIndex: ret.selectedFileIndex,
22792
+ selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
22793
+ filter: ret.filter,
22794
+ filterMode: false,
22795
+ pendingCommitFocused: false,
22796
+ pendingKey: undefined,
22797
+ pendingConfirmationId: undefined,
22798
+ pendingConfirmationPayload: undefined,
22799
+ pendingMutationConfirmation: undefined,
22800
+ };
22801
+ }
22567
22802
  function withReplacedView(state, value) {
22568
22803
  if (topOfStack(state.viewStack) === value) {
22569
22804
  return { ...state, pendingKey: undefined };
@@ -22708,7 +22943,7 @@ function createLogInkState(rows, options = {}) {
22708
22943
  selectedPullRequestTriageIndex: 0,
22709
22944
  selectedIssueFilter: 'open',
22710
22945
  selectedPullRequestFilter: 'open',
22711
- repoStack: [{ label: options.repoLabel || 'root' }],
22946
+ repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
22712
22947
  branchSort: DEFAULT_BRANCH_SORT_MODE,
22713
22948
  tagSort: DEFAULT_TAG_SORT_MODE,
22714
22949
  paletteFilter: '',
@@ -22721,6 +22956,7 @@ function createLogInkState(rows, options = {}) {
22721
22956
  filterMode: false,
22722
22957
  fullGraph: false,
22723
22958
  showHelp: false,
22959
+ helpScrollOffset: 0,
22724
22960
  showCommandPalette: false,
22725
22961
  workflowActionId: undefined,
22726
22962
  pendingConfirmationId: undefined,
@@ -22728,8 +22964,13 @@ function createLogInkState(rows, options = {}) {
22728
22964
  pendingMutationConfirmation: undefined,
22729
22965
  pendingKey: undefined,
22730
22966
  focus: 'commits',
22731
- sidebarTab: 'status',
22732
- userSidebarTab: 'status',
22967
+ // Default first-time tab is 'branches' — it's the most useful
22968
+ // landing surface in the workstation (current branch + recent
22969
+ // branches with ahead/behind, switch target, etc.). Users who
22970
+ // pick a different tab have their choice persisted per-repo via
22971
+ // sidebarPersistence.ts and won't see this default again.
22972
+ sidebarTab: 'branches',
22973
+ userSidebarTab: 'branches',
22733
22974
  sidebarHeaderFocused: false,
22734
22975
  statusGroupHeaderFocused: false,
22735
22976
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
@@ -22750,6 +22991,15 @@ function getSelectedInkCommit(state) {
22750
22991
  }
22751
22992
  return state.filteredCommits[state.selectedIndex];
22752
22993
  }
22994
+ /**
22995
+ * True when the user has drilled into a submodule (or deeper).
22996
+ * Drives the chrome breadcrumb's display and any future
22997
+ * frame-aware behavior that wants to know "are we in a nested
22998
+ * frame?" without inspecting the stack directly.
22999
+ */
23000
+ function isLogInkNestedRepo(state) {
23001
+ return state.repoStack.length > 1;
23002
+ }
22753
23003
  function applyLogInkAction(state, action) {
22754
23004
  switch (action.type) {
22755
23005
  case 'appendRows':
@@ -23172,6 +23422,14 @@ function applyLogInkAction(state, action) {
23172
23422
  return withPoppedView(state);
23173
23423
  case 'replaceView':
23174
23424
  return withReplacedView(state, action.value);
23425
+ case 'pushRepoFrame':
23426
+ return withPushedRepoFrame(state, {
23427
+ label: action.label,
23428
+ workdir: action.workdir,
23429
+ entryRange: action.entryRange,
23430
+ });
23431
+ case 'popRepoFrame':
23432
+ return withPoppedRepoFrame(state);
23175
23433
  case 'navigateHome': {
23176
23434
  if (state.viewStack.length === 1 && topOfStack(state.viewStack) === HOME_VIEW) {
23177
23435
  return { ...state, pendingKey: undefined };
@@ -23343,6 +23601,7 @@ function applyLogInkAction(state, action) {
23343
23601
  filterMode: !state.filterMode,
23344
23602
  showCommandPalette: false,
23345
23603
  showHelp: false,
23604
+ helpScrollOffset: 0,
23346
23605
  pendingKey: undefined,
23347
23606
  };
23348
23607
  case 'toggleGraph':
@@ -23351,19 +23610,35 @@ function applyLogInkAction(state, action) {
23351
23610
  fullGraph: !state.fullGraph,
23352
23611
  pendingKey: undefined,
23353
23612
  };
23354
- case 'toggleHelp':
23613
+ case 'toggleHelp': {
23614
+ const opening = !state.showHelp;
23355
23615
  return {
23356
23616
  ...state,
23357
- showHelp: !state.showHelp,
23617
+ showHelp: opening,
23618
+ // Reset scroll position when toggling either direction so the
23619
+ // next open always starts at the top — feels more predictable
23620
+ // than picking up where the user last scrolled.
23621
+ helpScrollOffset: 0,
23358
23622
  showCommandPalette: false,
23359
23623
  pendingKey: undefined,
23360
23624
  };
23625
+ }
23626
+ case 'scrollHelp':
23627
+ // No upper-bound clamp here — the renderer caps the offset
23628
+ // against the actual content height at render time. The
23629
+ // reducer just prevents going below 0 so callers can safely
23630
+ // pass negative deltas without us going past the top.
23631
+ return {
23632
+ ...state,
23633
+ helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta),
23634
+ };
23361
23635
  case 'toggleCommandPalette': {
23362
23636
  const opening = !state.showCommandPalette;
23363
23637
  return {
23364
23638
  ...state,
23365
23639
  showCommandPalette: opening,
23366
23640
  showHelp: false,
23641
+ helpScrollOffset: 0,
23367
23642
  // Reset palette interaction state on every open/close so the next
23368
23643
  // session starts from a clean slate.
23369
23644
  paletteFilter: '',
@@ -24010,6 +24285,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
24010
24285
  value: 'open branches / tags / history and press m on the cursored ref',
24011
24286
  })];
24012
24287
  case 'navigateBack':
24288
+ // Mirror the Esc / `<` semantics (#931): drain the frame's view
24289
+ // stack first, then pop the frame itself when nested.
24290
+ if (state.viewStack.length > 1) {
24291
+ return [action({ type: 'popView' })];
24292
+ }
24293
+ if (isLogInkNestedRepo(state)) {
24294
+ return [action({ type: 'popRepoFrame' })];
24295
+ }
24013
24296
  return [action({ type: 'popView' })];
24014
24297
  case 'openSelected': {
24015
24298
  // From history → diff for selected commit; from status → diff for
@@ -24557,8 +24840,37 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24557
24840
  }
24558
24841
  return [];
24559
24842
  }
24560
- if (key.escape && state.showHelp) {
24561
- return [action({ type: 'toggleHelp' })];
24843
+ // Help-overlay key handling. While help is open we intercept ALL
24844
+ // keys here and return before they can fall through to scroll /
24845
+ // focus / navigation logic below. Without this, j/k while help is
24846
+ // open routes into `moveDetailFile`-style handlers, which mutates
24847
+ // focus state (`focus: 'detail'` → `'commits'` or `'sidebar'`) —
24848
+ // exactly the "scroll loses focus" bug.
24849
+ //
24850
+ // Allowed: Esc / ? (close), q (quit), j/k/arrows (scroll), Ctrl-d/u
24851
+ // (half-page). Everything else is swallowed by the trailing
24852
+ // `return []` so a stray keypress can't drop the user into the
24853
+ // wrong surface.
24854
+ if (state.showHelp) {
24855
+ if (key.escape || inputValue === '?') {
24856
+ return [action({ type: 'toggleHelp' })];
24857
+ }
24858
+ if (inputValue === 'q') {
24859
+ return [{ type: 'exit' }];
24860
+ }
24861
+ if (key.downArrow || inputValue === 'j') {
24862
+ return [action({ type: 'scrollHelp', delta: 1 })];
24863
+ }
24864
+ if (key.upArrow || inputValue === 'k') {
24865
+ return [action({ type: 'scrollHelp', delta: -1 })];
24866
+ }
24867
+ if (key.ctrl && inputValue === 'd') {
24868
+ return [action({ type: 'scrollHelp', delta: 10 })];
24869
+ }
24870
+ if (key.ctrl && inputValue === 'u') {
24871
+ return [action({ type: 'scrollHelp', delta: -10 })];
24872
+ }
24873
+ return [];
24562
24874
  }
24563
24875
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
24564
24876
  // BEFORE the generic `popView` so we both clear the wizard state
@@ -24579,6 +24891,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24579
24891
  if (key.escape && state.viewStack.length > 1) {
24580
24892
  return [action({ type: 'popView' })];
24581
24893
  }
24894
+ // #931 — Esc auto-pop. When the user has drilled into a submodule
24895
+ // (nested repo frame) AND they're at the root of that frame's own
24896
+ // view stack, Esc walks back out to the parent repo. Ordered after
24897
+ // the view-stack pop above so Esc still drains a frame's view stack
24898
+ // before popping the frame itself — the user sees a predictable
24899
+ // "back, back, back" path out.
24900
+ if (key.escape && isLogInkNestedRepo(state)) {
24901
+ return [action({ type: 'popRepoFrame' })];
24902
+ }
24582
24903
  if (inputValue === 'q') {
24583
24904
  if (hasUnsavedComposeDraft(state)) {
24584
24905
  return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
@@ -24861,6 +25182,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24861
25182
  return [action({ type: 'toggleGraph' })];
24862
25183
  }
24863
25184
  if (inputValue === '<') {
25185
+ // #931 — `<` is the keymap-driven mirror of Esc auto-pop. When the
25186
+ // view stack has somewhere to go, pop a view; otherwise, if we're
25187
+ // in a nested submodule frame, walk back out to the parent. The
25188
+ // `popView` action is itself a no-op at the root of a frame's
25189
+ // view stack, so this ordering can't double-pop.
25190
+ if (state.viewStack.length > 1) {
25191
+ return [action({ type: 'popView' })];
25192
+ }
25193
+ if (isLogInkNestedRepo(state)) {
25194
+ return [action({ type: 'popRepoFrame' })];
25195
+ }
24864
25196
  return [action({ type: 'popView' })];
24865
25197
  }
24866
25198
  if (inputValue === 'G') {
@@ -25343,6 +25675,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25343
25675
  }
25344
25676
  }
25345
25677
  }
25678
+ // #931 PR 3b — Enter on a submodule file in a commit diff drills into
25679
+ // the submodule's history (the "spawn a coco ui scoped to the
25680
+ // submodule" mental model from the design doc). The runtime decides
25681
+ // whether the cursored file is a drill-in candidate and resolves the
25682
+ // workdir + entryRange ahead of time; the handler here only fires
25683
+ // when that target is populated. Ordered before the generic file-
25684
+ // list Enter handler so the drill-in takes precedence over the
25685
+ // detail-panel diff-refocus path.
25686
+ if (key.return &&
25687
+ state.activeView === 'diff' &&
25688
+ state.diffSource === 'commit' &&
25689
+ context.commitDiffSubmoduleDrillIn) {
25690
+ const target = context.commitDiffSubmoduleDrillIn;
25691
+ return [
25692
+ action({
25693
+ type: 'pushRepoFrame',
25694
+ label: target.label,
25695
+ workdir: target.workdir,
25696
+ entryRange: target.entryRange,
25697
+ }),
25698
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25699
+ ];
25700
+ }
25701
+ // #931 PR 4 / #932 — Enter on a row in the dedicated submodules view
25702
+ // drills into that submodule's history. Same mental model as the
25703
+ // commit-diff drill-in (PR 3b) — pushing a frame is the equivalent
25704
+ // of `cd vendor/lib && coco ui`. No entry range here; the submodules
25705
+ // view doesn't carry diff context, so the frame lands on the
25706
+ // submodule's full history.
25707
+ if (key.return &&
25708
+ isSubmodulesActionTarget(state) &&
25709
+ context.submoduleViewDrillIn) {
25710
+ const target = context.submoduleViewDrillIn;
25711
+ return [
25712
+ action({
25713
+ type: 'pushRepoFrame',
25714
+ label: target.label,
25715
+ workdir: target.workdir,
25716
+ }),
25717
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25718
+ ];
25719
+ }
25346
25720
  if (key.return &&
25347
25721
  state.activeView === 'history' &&
25348
25722
  state.focus === 'commits' &&
@@ -26250,6 +26624,199 @@ function pickSpinnerFrame(tick) {
26250
26624
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
26251
26625
  }
26252
26626
 
26627
+ /**
26628
+ * Build the initial `LogInkContextStatus` for a freshly-created frame
26629
+ * (#931). Every fetched key starts in `'loading'` so surfaces show the
26630
+ * loading hint immediately; `pullRequest` is the exception (#808) —
26631
+ * it's lazy-loaded on entry to the PR view, so we seed it `'idle'`
26632
+ * instead of leaving it stuck as a permanent "loading" flag in the
26633
+ * chrome.
26634
+ *
26635
+ * Extracted so the root runtime (built at boot inside `LogInkApp`) and
26636
+ * the per-frame factory below share one canonical seed. The status
26637
+ * surfaces depend on the exact `'pullRequest' = 'idle'` initialization
26638
+ * to avoid spurious loading hints; locking it down in one helper means
26639
+ * the two code paths can't drift.
26640
+ */
26641
+ function createInitialContextStatus() {
26642
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
26643
+ }
26644
+ /**
26645
+ * Factory that builds a fresh `RepoFrameRuntime` for a newly-pushed
26646
+ * frame (#931). The frame's `workdir` (set by the push action) drives
26647
+ * which working tree the `SimpleGit` instance binds against:
26648
+ *
26649
+ * - **Has workdir** → `simpleGit(workdir)`. Production case for any
26650
+ * nested submodule frame.
26651
+ * - **No workdir** → falls back to `rootGit`. Defensive: only the
26652
+ * root frame is expected to lack a workdir, and the root frame's
26653
+ * runtime is built directly from `rootGit` in `LogInkApp`'s state
26654
+ * initializer — this fallback only kicks in if a future push path
26655
+ * forgets to pass `workdir`. Binding to the root keeps the session
26656
+ * functional (the user still sees data) at the cost of the frame
26657
+ * being a duplicate of the root.
26658
+ *
26659
+ * `context` starts empty; `contextStatus` starts in the same initial
26660
+ * "loading + pullRequest idle" shape the root frame seeds with. The
26661
+ * sync effect in `LogInkApp` is responsible for kicking off the
26662
+ * per-key context loads against the new frame's `git`; we don't do
26663
+ * that here so the factory stays pure and unit-testable without a
26664
+ * real repo on disk.
26665
+ */
26666
+ function createRepoFrameRuntime(frame, rootGit) {
26667
+ return {
26668
+ git: frame.workdir ? simpleGit.simpleGit(frame.workdir) : rootGit,
26669
+ context: {},
26670
+ contextStatus: createInitialContextStatus(),
26671
+ };
26672
+ }
26673
+
26674
+ /**
26675
+ * Pure resolver: given the cursored file + the active frame's
26676
+ * submodule overview + repo root, decide whether a commit-diff Enter
26677
+ * keystroke should drill into a submodule and, if so, what payload
26678
+ * the `pushRepoFrame` action should carry.
26679
+ *
26680
+ * Returns undefined when:
26681
+ * - We don't know the active repo root yet (boot still in flight).
26682
+ * - The file's path doesn't correspond to a registered submodule.
26683
+ * - The submodule overview hasn't loaded yet for the active frame.
26684
+ *
26685
+ * The `submoduleChange` on the file preview is the source of truth
26686
+ * for the entry range; we never need to re-run the diff to populate
26687
+ * the (oldSha, newSha) pair.
26688
+ */
26689
+ function resolveCommitDiffDrillInTarget(args) {
26690
+ const { selectedFile, submodules, activeRepoRoot } = args;
26691
+ if (!activeRepoRoot)
26692
+ return undefined;
26693
+ if (!submodules || !submodules.hasSubmodules)
26694
+ return undefined;
26695
+ const entry = findSubmoduleByPath(submodules, selectedFile.path);
26696
+ if (!entry)
26697
+ return undefined;
26698
+ return {
26699
+ label: entry.name,
26700
+ workdir: path.join(activeRepoRoot, entry.path),
26701
+ entryRange: deriveEntryRange(selectedFile.submoduleChange),
26702
+ };
26703
+ }
26704
+ /**
26705
+ * Convert the structured `SubmoduleChange` (from `extractSubmoduleChange`)
26706
+ * into the `entryRange` shape `LogInkRepoFrame` carries. Modified
26707
+ * submodules surface both shas; added / removed surface only one,
26708
+ * which isn't enough to scope a history range — those cases return
26709
+ * undefined and the frame lands on the submodule's full history.
26710
+ */
26711
+ function deriveEntryRange(change) {
26712
+ if (!change)
26713
+ return undefined;
26714
+ if (change.kind === 'modified') {
26715
+ return { oldSha: change.before, newSha: change.after };
26716
+ }
26717
+ return undefined;
26718
+ }
26719
+ /**
26720
+ * Pure resolver for the submodules-view drill-in (#931 PR 4 / #932).
26721
+ * Given the cursored row index + the submodule overview + the active
26722
+ * frame's repo root, build the `pushRepoFrame` payload Enter should
26723
+ * dispatch. Returns undefined when:
26724
+ *
26725
+ * - The active repo root hasn't loaded yet.
26726
+ * - The submodule overview hasn't loaded (or is empty).
26727
+ * - The cursor is past the end of the entries (race between a
26728
+ * refresh that removed a submodule and a key press still in
26729
+ * flight against the old length).
26730
+ * - The cursored entry has no `path` recorded. The `.gitmodules`
26731
+ * parser already filters these out upstream, but the resolver
26732
+ * defends against it so the cursor can't yank the user into a
26733
+ * workdir-less frame.
26734
+ */
26735
+ function resolveSubmoduleViewDrillInTarget(args) {
26736
+ const { selectedIndex, submodules, activeRepoRoot } = args;
26737
+ if (!activeRepoRoot)
26738
+ return undefined;
26739
+ if (!submodules || !submodules.hasSubmodules)
26740
+ return undefined;
26741
+ const entry = submodules.entries[selectedIndex];
26742
+ if (!entry || !entry.path)
26743
+ return undefined;
26744
+ return {
26745
+ label: entry.name,
26746
+ workdir: path.join(activeRepoRoot, entry.path),
26747
+ };
26748
+ }
26749
+
26750
+ /**
26751
+ * Reconcile the per-frame runtime list against the current view-model
26752
+ * stack. Three cases:
26753
+ *
26754
+ * - **No change** — same length, returns `prev` unchanged so React
26755
+ * reference equality skips downstream re-renders.
26756
+ * - **Pop** — stack shrunk, returns `prev.slice(0, stack.length)`.
26757
+ * The dropped runtimes are released to the GC; the surviving
26758
+ * runtimes (root + any intermediate frames) keep their cached
26759
+ * `git` + `context` so a re-push lands on warm state.
26760
+ * - **Push** — stack grew, builds a fresh runtime via the supplied
26761
+ * `createRuntime(frame, depth)` factory for each newly-deeper
26762
+ * frame. The caller is responsible for the factory's content;
26763
+ * this module never imports `simple-git` or `loadLogInkContext`
26764
+ * directly so it stays unit-testable without a real repo on disk.
26765
+ *
26766
+ * Returns `newlyAddedIndices` so the caller's effect knows which
26767
+ * frames need their initial context fetch kicked off. On a no-op or
26768
+ * pop, the list is empty.
26769
+ *
26770
+ * The reducer's `pushRepoFrame` / `popRepoFrame` actions are the only
26771
+ * things that mutate `state.repoStack`; both are monotone — push
26772
+ * appends one, pop drops one — so this helper never needs to handle
26773
+ * "frame at index i changed identity in place." If that invariant ever
26774
+ * loosens, this helper should error rather than silently mis-bind a
26775
+ * `SimpleGit` to the wrong working directory.
26776
+ */
26777
+ function syncRepoStackRuntimes(prev, stack, createRuntime) {
26778
+ if (stack.length < prev.length) {
26779
+ return { runtimes: prev.slice(0, stack.length), newlyAddedIndices: [] };
26780
+ }
26781
+ if (stack.length === prev.length) {
26782
+ return { runtimes: prev, newlyAddedIndices: [] };
26783
+ }
26784
+ const next = prev.slice();
26785
+ const newlyAddedIndices = [];
26786
+ for (let i = prev.length; i < stack.length; i += 1) {
26787
+ next.push(createRuntime(stack[i], i));
26788
+ newlyAddedIndices.push(i);
26789
+ }
26790
+ return { runtimes: next, newlyAddedIndices };
26791
+ }
26792
+ /**
26793
+ * Top-of-stack runtime — the one every active surface, loader, and
26794
+ * action target reads from. Undefined when the runtime list is empty
26795
+ * (which production code never produces — `createLogInkState` always
26796
+ * seeds a root frame, so the corresponding root runtime is built on
26797
+ * mount and the array is non-empty for the lifetime of the session).
26798
+ */
26799
+ function getActiveRepoFrameRuntime(runtimes) {
26800
+ return runtimes[runtimes.length - 1];
26801
+ }
26802
+ /**
26803
+ * Immutably update one frame's runtime entry. Used by the app shell's
26804
+ * loader effects when a frame's `context` or `contextStatus` changes
26805
+ * — replacing the entry in place lets React's referential equality
26806
+ * skip re-renders on unrelated frames.
26807
+ *
26808
+ * Out-of-range indices are no-ops (return `prev` unchanged) so the
26809
+ * caller doesn't have to guard against race-y stack changes between
26810
+ * the load kickoff and the load-complete callback.
26811
+ */
26812
+ function updateRepoFrameRuntime(runtimes, index, updater) {
26813
+ if (index < 0 || index >= runtimes.length)
26814
+ return runtimes;
26815
+ const next = runtimes.slice();
26816
+ next[index] = updater(next[index]);
26817
+ return next;
26818
+ }
26819
+
26253
26820
  /**
26254
26821
  * Persist the user's preferred diff view mode (unified vs side-by-side
26255
26822
  * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
@@ -28823,7 +29390,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
28823
29390
  ? ' loading commits'
28824
29391
  : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
28825
29392
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
28826
- const view = breadcrumb ? ` ${breadcrumb}` : '';
29393
+ const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
29394
+ // Repo breadcrumb (when nested) comes first so the user sees which
29395
+ // submodule they're in at a glance, then the view breadcrumb (when
29396
+ // pushed deeper than the root view). The truncate fallback in the
29397
+ // title row still applies — when both fight for space, the ellipsis
29398
+ // lands at the end of whichever segment overflows.
29399
+ const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
28827
29400
  // Mode indicator (P2.2) — surfaces the current input mode so users
28828
29401
  // never wonder why `q` doesn't quit while they're editing or filtering.
28829
29402
  const mode = state.commitCompose.editing
@@ -30471,30 +31044,52 @@ function filterChippedRefs(refs, chip) {
30471
31044
  return true;
30472
31045
  });
30473
31046
  }
30474
- function getBranchTipChip(refs) {
31047
+ /**
31048
+ * `remoteNames` lets the caller pass the repository's actual remote
31049
+ * names (e.g. `['origin', 'upstream']`) so refs are classified by
31050
+ * remote-prefix rather than by "contains a slash". Without it a local
31051
+ * feature branch like `feat/x` looks identical to a remote-tracking
31052
+ * `origin/x` and gets the wrong colour. When the list is omitted the
31053
+ * function falls back to the legacy slash-as-remote heuristic — the
31054
+ * sensible default before branch data has loaded and a back-compat
31055
+ * affordance for callers that have no remote data to hand.
31056
+ */
31057
+ function getBranchTipChip(refs, remoteNames) {
31058
+ // Empty list is treated the same as omitted: branch data hasn't
31059
+ // loaded yet, so we don't have ground truth and the legacy "slash =
31060
+ // remote" heuristic is the best guess for first paint.
31061
+ const hasRemoteList = Array.isArray(remoteNames) && remoteNames.length > 0;
31062
+ const isRemoteRef = (ref) => {
31063
+ if (!ref.includes('/'))
31064
+ return false;
31065
+ if (!hasRemoteList)
31066
+ return true;
31067
+ return remoteNames.some((remote) => remote && ref.startsWith(`${remote}/`));
31068
+ };
30475
31069
  for (const ref of refs) {
30476
31070
  if (ref.startsWith('HEAD -> ')) {
30477
31071
  const name = ref.slice('HEAD -> '.length).trim();
30478
31072
  if (name)
30479
- return { name, isHead: true };
31073
+ return { name, isHead: true, kind: 'head' };
30480
31074
  }
30481
31075
  }
30482
31076
  for (const ref of refs) {
30483
31077
  if (ref === 'HEAD' ||
30484
31078
  ref.startsWith('HEAD -> ') ||
30485
31079
  ref.startsWith('tag: ') ||
30486
- ref.includes('/')) {
31080
+ isRemoteRef(ref)) {
30487
31081
  continue;
30488
31082
  }
30489
- if (ref.trim())
30490
- return { name: ref.trim(), isHead: false };
31083
+ if (ref.trim()) {
31084
+ return { name: ref.trim(), isHead: false, kind: 'local' };
31085
+ }
30491
31086
  }
30492
31087
  for (const ref of refs) {
30493
31088
  if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
30494
31089
  continue;
30495
31090
  }
30496
- if (ref.includes('/') && ref.trim()) {
30497
- return { name: ref.trim(), isHead: false };
31091
+ if (isRemoteRef(ref) && ref.trim()) {
31092
+ return { name: ref.trim(), isHead: false, kind: 'remote' };
30498
31093
  }
30499
31094
  }
30500
31095
  return undefined;
@@ -31257,8 +31852,12 @@ const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
31257
31852
  * descriptor so the caller can pass it to `filterChippedRefs` and
31258
31853
  * avoid emitting the same branch a second time in the trailing list.
31259
31854
  */
31260
- function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31261
- const chip = getBranchTipChip(commit.refs);
31855
+ // Exported for unit / snapshot testing in branchTipChipRender.test.ts.
31856
+ // The function isn't part of the public surface of this module — the
31857
+ // rest of the file is internal — but the chip-rendering logic is
31858
+ // dense enough that structural snapshot tests pay for themselves.
31859
+ function renderBranchTipChip(h, Text, commit, theme, key, selected, remoteNames) {
31860
+ const chip = getBranchTipChip(commit.refs, remoteNames);
31262
31861
  if (!chip)
31263
31862
  return { node: null, width: 0, chip };
31264
31863
  const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
@@ -31279,7 +31878,23 @@ function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31279
31878
  chip,
31280
31879
  };
31281
31880
  }
31282
- const accent = chip.isHead ? theme.colors.success : theme.colors.info;
31881
+ // Three-way colour assignment matches `BranchTipChipKind`:
31882
+ //
31883
+ // - HEAD → success (the user's current branch — bright green)
31884
+ // - local → info (other local branches — calm blue)
31885
+ // - remote → warning (remote-tracking refs like origin/main —
31886
+ // distinct so "where is upstream?" reads at a glance)
31887
+ //
31888
+ // Without the remote/local split, a chip on `origin/main` looked
31889
+ // identical to a local-branch chip, so users couldn't tell from the
31890
+ // commit list where their upstream actually pointed. The warning hue
31891
+ // (typically a muted yellow / orange) is purposeful: not alarming,
31892
+ // but visibly different from the local blue.
31893
+ const accent = chip.kind === 'head'
31894
+ ? theme.colors.success
31895
+ : chip.kind === 'remote'
31896
+ ? theme.colors.warning
31897
+ : theme.colors.info;
31283
31898
  return {
31284
31899
  node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
31285
31900
  width: cellWidth(body) + 1,
@@ -31369,7 +31984,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
31369
31984
  * Truncation is per-segment so the variable-length message field gets
31370
31985
  * the leftover budget after fixed segments are accounted for.
31371
31986
  */
31372
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
31987
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false, remoteNames) {
31373
31988
  // Total cells available to the row content. Earlier revisions used a
31374
31989
  // hardcoded 140 here, which let row content overflow whenever the
31375
31990
  // panel was narrower than that — Ink would wrap onto a second visual
@@ -31386,7 +32001,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31386
32001
  // out whatever the chip already shows so the row doesn't print
31387
32002
  // `[main] feat: x [HEAD -> main]` with the same info on both ends.
31388
32003
  const chip = fullGraph
31389
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
32004
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected, remoteNames)
31390
32005
  : { node: null, width: 0, chip: undefined };
31391
32006
  const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31392
32007
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
@@ -31448,7 +32063,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31448
32063
  * line stays dim regardless of selection so it doesn't pull the eye
31449
32064
  * away from the subject.
31450
32065
  */
31451
- function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
32066
+ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false, remoteNames) {
31452
32067
  const totalWidth = Math.max(20, panelWidth - 4);
31453
32068
  const accent = theme.noColor ? undefined : theme.colors.accent;
31454
32069
  const muted = theme.noColor ? undefined : theme.colors.muted;
@@ -31459,7 +32074,7 @@ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth,
31459
32074
  // same way as the single-line variant, but only in full-graph mode.
31460
32075
  const recentMarkerWidth = isRecent ? 2 : 0;
31461
32076
  const chip = fullGraph
31462
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
32077
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected, remoteNames)
31463
32078
  : { node: null, width: 0, chip: undefined };
31464
32079
  const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
31465
32080
  const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
@@ -31534,6 +32149,17 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31534
32149
  const { Box, Text } = components;
31535
32150
  const focused = state.focus === 'commits';
31536
32151
  const worktree = context.worktree;
32152
+ // Distinct remote names seen across the repo's remote-tracking
32153
+ // branches — `['origin']` for a typical fork, `['origin', 'upstream']`
32154
+ // when the user has both. Used to classify branch-tip chips so a
32155
+ // slashed local branch like `feat/x` doesn't get mis-coloured as
32156
+ // remote. When branch data hasn't loaded yet, `undefined` makes the
32157
+ // chip helper fall back to the legacy slash-based heuristic.
32158
+ const remoteNames = context.branches?.remoteBranches
32159
+ ? Array.from(new Set(context.branches.remoteBranches
32160
+ .map((branch) => branch.remote)
32161
+ .filter((remote) => Boolean(remote))))
32162
+ : undefined;
31537
32163
  // Set of just-landed commit hashes for the "new commit" marker.
31538
32164
  // Populated for ~5s after a split-apply or other commit-creating
31539
32165
  // operation; auto-cleared by the runtime so it doesn't linger.
@@ -31642,9 +32268,9 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31642
32268
  }, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
31643
32269
  }
31644
32270
  if (rowMode === 'stacked') {
31645
- return renderStackedCommitHistoryRow(h, Text, Box, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, state.fullGraph, now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
32271
+ return renderStackedCommitHistoryRow(h, Text, Box, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, state.fullGraph, now, item.laneSegments, recentCommitsSet.has(item.commit.hash), remoteNames);
31646
32272
  }
31647
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, density, state.fullGraph, Boolean(dateBucketingNow), now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
32273
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, density, state.fullGraph, Boolean(dateBucketingNow), now, item.laneSegments, recentCommitsSet.has(item.commit.hash), remoteNames);
31648
32274
  }));
31649
32275
  }
31650
32276
 
@@ -31800,20 +32426,38 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
31800
32426
  }
31801
32427
  function renderHelpPanel(h, components, state, width, theme, focused) {
31802
32428
  const { Box, Text } = components;
31803
- const children = [
31804
- h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
31805
- ];
32429
+ // Build the full list of body rows (everything below the title).
32430
+ // Splitting into title + body lets us window the body by
32431
+ // `state.helpScrollOffset` while keeping the title pinned.
32432
+ const body = [];
31806
32433
  const sections = getLogInkHelpSections({
31807
32434
  activeView: state.activeView,
31808
32435
  focus: state.focus,
31809
32436
  });
31810
32437
  for (const section of sections) {
31811
- children.push(h(Text, { key: `${section.title}-spacer` }, ''));
31812
- children.push(h(Text, { bold: true, key: section.title }, section.title));
32438
+ body.push(h(Text, { key: `${section.title}-spacer` }, ''));
32439
+ body.push(h(Text, { bold: true, key: section.title }, section.title));
31813
32440
  section.bindings.forEach((binding) => {
31814
- children.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
32441
+ body.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
31815
32442
  });
31816
32443
  }
32444
+ // Clamp the offset against actual content length. The reducer
32445
+ // only floor-clamps at 0; here we ceiling-clamp so j past EOF
32446
+ // sticks at the last row rather than scrolling into emptiness.
32447
+ // Reserve one row at the bottom so the user can always see the
32448
+ // tail of the last section.
32449
+ const maxOffset = Math.max(0, body.length - 1);
32450
+ const offset = Math.min(state.helpScrollOffset, maxOffset);
32451
+ const children = [
32452
+ h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
32453
+ ];
32454
+ // Visual hint that there's content scrolled above. The dim style
32455
+ // matches the rest of the chrome's "metadata" voice and avoids
32456
+ // stealing attention from the bindings themselves.
32457
+ if (offset > 0) {
32458
+ children.push(h(Text, { key: 'more-above', dimColor: true }, '↑ more above'));
32459
+ }
32460
+ children.push(...body.slice(offset));
31817
32461
  return h(Box, {
31818
32462
  borderColor: focusBorderColor(theme, focused),
31819
32463
  borderStyle: theme.borderStyle,
@@ -34549,7 +35193,7 @@ function enrichFilterActionWithRectification(action, state, context) {
34549
35193
  }
34550
35194
  }
34551
35195
  function LogInkApp(deps) {
34552
- const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
35196
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
34553
35197
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
34554
35198
  const h = React.createElement;
34555
35199
  const { exit } = useApp();
@@ -34580,16 +35224,98 @@ function LogInkApp(deps) {
34580
35224
  // immediately while the chrome still flags the refresh.
34581
35225
  bootLoading: Boolean(loadRows),
34582
35226
  }));
34583
- const [context, setContext] = React.useState({});
34584
- const [contextStatus, setContextStatus] = React.useState(() => {
34585
- // Boot starts every fetched key in 'loading' so the surfaces show
34586
- // their loading hints immediately. `pullRequest` is the exception
34587
- // (#808) it isn't part of the boot fetch entries; it lazy-loads
34588
- // when the user enters the PR view. Marking it 'idle' avoids a
34589
- // permanent "loading" flag in the chrome and lets the dedicated
34590
- // PR view's own load effect drive its loading state.
34591
- return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
34592
- });
35227
+ // Nested-repo runtime stack (#931). Each frame holds the live
35228
+ // `SimpleGit`, the loaded `LogInkContext`, and the per-key load
35229
+ // status the chrome reads. The active (top-of-stack) entry drives
35230
+ // every loader and surface; popping a frame restores the parent's
35231
+ // cached entry so a drill-in / drill-out round trip doesn't re-pay
35232
+ // the context load cost. Seeded with a single root runtime against
35233
+ // the cwd `coco ui` was launched in.
35234
+ const [runtimes, setRuntimes] = React.useState(() => [{
35235
+ git: rootGit,
35236
+ context: {},
35237
+ contextStatus: createInitialContextStatus(),
35238
+ }]);
35239
+ // Sync `runtimes` against the view-model stack on every push / pop.
35240
+ // The sync is monotone — push appends a new runtime via the factory,
35241
+ // pop slices off the top runtime; the parent's cached state survives.
35242
+ // The factory is wrapped to capture `rootGit` so a defensively-pushed
35243
+ // frame without a workdir still has a working `SimpleGit` bound.
35244
+ React.useEffect(() => {
35245
+ setRuntimes((prev) => {
35246
+ const { runtimes: next } = syncRepoStackRuntimes(prev, state.repoStack, (frame) => createRepoFrameRuntime(frame, rootGit));
35247
+ return next;
35248
+ });
35249
+ }, [state.repoStack, rootGit]);
35250
+ // Active-frame projection (#931). `git`, `context`, `contextStatus`
35251
+ // — every existing closure / effect / surface reads these names; the
35252
+ // only thing this PR changes is where they come from. When the user
35253
+ // drills into a submodule, the top-of-stack runtime swaps, every
35254
+ // dep array that lists `git` re-fires, and the loaders refetch
35255
+ // against the submodule's working tree.
35256
+ const activeRuntime = getActiveRepoFrameRuntime(runtimes) ?? {
35257
+ git: rootGit,
35258
+ context: {},
35259
+ contextStatus: createInitialContextStatus(),
35260
+ };
35261
+ const git = activeRuntime.git;
35262
+ const context = activeRuntime.context;
35263
+ const contextStatus = activeRuntime.contextStatus;
35264
+ // Wrappers that delegate to the active frame's runtime entry so the
35265
+ // existing call sites stay byte-identical. Support both function-
35266
+ // updater and value-updater forms (the codebase uses both).
35267
+ const setContext = React.useCallback((arg) => {
35268
+ setRuntimes((prev) => {
35269
+ const depth = prev.length - 1;
35270
+ if (depth < 0)
35271
+ return prev;
35272
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35273
+ ...frame,
35274
+ context: typeof arg === 'function'
35275
+ ? arg(frame.context)
35276
+ : arg,
35277
+ }));
35278
+ });
35279
+ }, []);
35280
+ const setContextStatus = React.useCallback((arg) => {
35281
+ setRuntimes((prev) => {
35282
+ const depth = prev.length - 1;
35283
+ if (depth < 0)
35284
+ return prev;
35285
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35286
+ ...frame,
35287
+ contextStatus: typeof arg === 'function'
35288
+ ? arg(frame.contextStatus)
35289
+ : arg,
35290
+ }));
35291
+ });
35292
+ }, []);
35293
+ // #931 PR 3b — Absolute repo root for the active frame's `git`.
35294
+ // Resolved asynchronously after every `git` swap (push / pop /
35295
+ // boot) so the commit-diff drill-in helper can construct absolute
35296
+ // workdirs for submodule paths recorded in `.gitmodules` (which
35297
+ // are repo-relative). Undefined during the brief moment between
35298
+ // git swap and the revparse callback resolving.
35299
+ const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
35300
+ React.useEffect(() => {
35301
+ let cancelled = false;
35302
+ void (async () => {
35303
+ try {
35304
+ const root = (await git.revparse(['--show-toplevel'])).trim();
35305
+ if (!cancelled && root) {
35306
+ setActiveRepoRoot(root);
35307
+ }
35308
+ }
35309
+ catch {
35310
+ if (!cancelled) {
35311
+ setActiveRepoRoot(undefined);
35312
+ }
35313
+ }
35314
+ })();
35315
+ return () => {
35316
+ cancelled = true;
35317
+ };
35318
+ }, [git]);
34593
35319
  const [detail, setDetail] = React.useState(undefined);
34594
35320
  const [detailLoading, setDetailLoading] = React.useState(false);
34595
35321
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -35109,9 +35835,26 @@ function LogInkApp(deps) {
35109
35835
  selectedWorktreeFile?.worktreeStatus,
35110
35836
  state.activeView,
35111
35837
  ]);
35838
+ // #931 PR 5 — Cache-aware boot load. The frame's `git` instance is
35839
+ // the dep that drives this effect; on push, the new frame's runtime
35840
+ // starts every key in `'loading'` and we fetch fresh. On pop, the
35841
+ // parent's runtime carries cached context across the drill-out cycle
35842
+ // (`'ready'` for already-loaded keys), and the per-key gate below
35843
+ // skips the fetch so the user's drill-out is instant + flicker-free.
35844
+ //
35845
+ // `contextStatusRef` reads the latest status without putting
35846
+ // `contextStatus` in the effect deps — including it would re-fire
35847
+ // the effect on every per-key 'ready' write the effect itself
35848
+ // produces, causing duplicate in-flight fetches for not-yet-completed
35849
+ // keys. The ref pattern gives us "read latest" semantics with the
35850
+ // effect still gated on git swaps only.
35851
+ const contextStatusRef = React.useRef(contextStatus);
35852
+ contextStatusRef.current = contextStatus;
35112
35853
  React.useEffect(() => {
35113
35854
  let active = true;
35114
35855
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
35856
+ if (contextStatusRef.current[key] === 'ready')
35857
+ return;
35115
35858
  void load().then((value) => {
35116
35859
  if (!active) {
35117
35860
  return;
@@ -37526,6 +38269,37 @@ function LogInkApp(deps) {
37526
38269
  commitDiffSelectedSha: state.diffSource === 'commit'
37527
38270
  ? selected?.hash
37528
38271
  : undefined,
38272
+ // #931 PR 3b — Submodule drill-in target for the cursored file
38273
+ // in a commit diff. Resolved per-render so the Enter handler in
38274
+ // `inkInput.ts` doesn't have to re-walk the submodule overview;
38275
+ // undefined whenever the cursored file isn't a registered
38276
+ // submodule (or the overview / repo root haven't loaded yet).
38277
+ commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
38278
+ ? resolveCommitDiffDrillInTarget({
38279
+ selectedFile: {
38280
+ path: selectedDetailFile.path,
38281
+ submoduleChange: filePreview?.path === selectedDetailFile.path
38282
+ ? filePreview.submoduleChange
38283
+ : undefined,
38284
+ },
38285
+ submodules: context.submodules,
38286
+ activeRepoRoot,
38287
+ })
38288
+ : undefined,
38289
+ // #931 PR 4 / #932 — Submodule drill-in target for the cursored
38290
+ // row in the dedicated submodules view. Resolved per-render so
38291
+ // the Enter handler in `inkInput.ts` doesn't have to re-walk the
38292
+ // submodule overview. Gated on `activeView === 'submodules'` so
38293
+ // a stale resolution from a different view can't accidentally
38294
+ // fire — the runtime only ever populates it when the user is
38295
+ // actually on the view.
38296
+ submoduleViewDrillIn: state.activeView === 'submodules'
38297
+ ? resolveSubmoduleViewDrillInTarget({
38298
+ selectedIndex: state.selectedSubmoduleIndex,
38299
+ submodules: context.submodules,
38300
+ activeRepoRoot,
38301
+ })
38302
+ : undefined,
37529
38303
  worktreeDirty,
37530
38304
  conflictFileCount: context.operation?.conflictedFiles.length,
37531
38305
  conflictSelectedPath: (() => {
@@ -38274,6 +39048,27 @@ function formatCommitDetail(detail, format) {
38274
39048
  ].join('\n');
38275
39049
  }
38276
39050
 
39051
+ /**
39052
+ * Friendly empty-repo message for the non-interactive log path.
39053
+ *
39054
+ * In `--json` mode we emit an empty array so machine consumers see a
39055
+ * well-defined "no commits" result without a parse error. In table
39056
+ * mode we print a human one-liner that names the next-step commands
39057
+ * the user is likely after. Either way we exit 0 — "no commits" is
39058
+ * a valid repo state, not a failure.
39059
+ */
39060
+ function formatEmptyRepoResult(format) {
39061
+ if (format === 'json') {
39062
+ return '[]';
39063
+ }
39064
+ return [
39065
+ "No commits yet — this looks like a fresh `git init`'d repo.",
39066
+ '',
39067
+ 'Get started:',
39068
+ ' • `coco commit` to draft your first commit message with AI',
39069
+ ' • `git commit -m "chore: initial commit"` to commit by hand',
39070
+ ].join('\n');
39071
+ }
38277
39072
  const handler$3 = async (argv) => {
38278
39073
  // `--repo <dir>` (alias `--cwd`) — apply the global flag via the
38279
39074
  // shared helper. After this returns, `process.cwd()` and the git
@@ -38289,6 +39084,22 @@ const handler$3 = async (argv) => {
38289
39084
  });
38290
39085
  return;
38291
39086
  }
39087
+ // Empty-repo short-circuit. Without this, the underlying `git log`
39088
+ // crashes the command and the user sees a raw "fatal: your current
39089
+ // branch 'main' does not have any commits yet" + a generic "Failed
39090
+ // to execute command" banner. We catch the unborn-HEAD state and
39091
+ // emit a friendly next-step hint (or an empty array in JSON mode)
39092
+ // and exit 0 — "no commits" is a valid repo state, not an error.
39093
+ //
39094
+ // Only applies to the non-interactive path: the TUI runtime gets
39095
+ // its own empty-state rendering inside the workstation.
39096
+ if (!argv.interactive && (await isEmptyRepo(git))) {
39097
+ await handleResult({
39098
+ result: formatEmptyRepoResult(format),
39099
+ mode: 'stdout',
39100
+ });
39101
+ return;
39102
+ }
38292
39103
  // Interactive path defers the commit log fetch into the runtime
38293
39104
  // (#808) so the TUI mounts immediately with a "Loading commits…"
38294
39105
  // placeholder. The non-interactive (stdout) path still needs rows