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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.51.0";
64
+ const BUILD_VERSION = "0.52.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -8212,9 +8212,40 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8212
8212
  return [];
8213
8213
  }
8214
8214
 
8215
+ async function refExists(git, ref) {
8216
+ try {
8217
+ // `--verify --quiet` suppresses stderr noise on a missing ref and
8218
+ // emits the resolved sha on stdout when it exists. simple-git
8219
+ // returns an empty string (rather than throwing) when git exits
8220
+ // 1 under `--quiet`, so the presence/absence check is on the
8221
+ // output, not on whether the call rejected.
8222
+ const out = await git.raw(['rev-parse', '--verify', '--quiet', ref]);
8223
+ return out.trim().length > 0;
8224
+ }
8225
+ catch {
8226
+ return false;
8227
+ }
8228
+ }
8215
8229
  /**
8216
8230
  * Retrieves the commit log for the current branch.
8217
8231
  *
8232
+ * Edge states that are not errors and should not be reported as such:
8233
+ *
8234
+ * - Detached HEAD (including mid-rebase and mid-bisect, which both
8235
+ * leave HEAD detached). There is no "current branch" to compare
8236
+ * against; the helper logs a yellow status line and returns [].
8237
+ * - Comparison ref missing — e.g. the repo has no `origin` remote,
8238
+ * so `origin/main` does not resolve; or the local comparison
8239
+ * branch (`main`) simply does not exist. Previously this threw
8240
+ * and surfaced as a red "Encountered an error" banner. Now we
8241
+ * probe the ref up front and report a clean status line.
8242
+ * - Empty rev-list output. The previous yellow "Unable to determine
8243
+ * first and last commit" wording read like an error; it's just
8244
+ * "no commits ahead of the comparison ref", which is the normal
8245
+ * outcome when the branch is at or behind its baseline.
8246
+ *
8247
+ * The catch block is reserved for genuinely unexpected git failures.
8248
+ *
8218
8249
  * @param {Object} options - The options for retrieving the commit log.
8219
8250
  * @param {SimpleGit} options.git - The SimpleGit instance.
8220
8251
  * @param {Logger} options.logger - The logger for logging messages.
@@ -8223,43 +8254,53 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8223
8254
  * @returns {Promise<CommitDetails[]>} The array of commit messages in the commit log.
8224
8255
  */
8225
8256
  async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main', comparisonRemote = 'origin', }) {
8257
+ const branchName = await getCurrentBranchName({ git });
8258
+ // Detached HEAD: `git rev-parse --abbrev-ref HEAD` returns the literal
8259
+ // string 'HEAD' in this state. Also covers mid-rebase and mid-bisect,
8260
+ // which both detach HEAD onto the picked / midpoint commit. There's
8261
+ // no branch to compare against, so don't pretend there was an error.
8262
+ if (!branchName || branchName === 'HEAD') {
8263
+ logger?.log('HEAD is detached (or a rebase / bisect is in progress) — no branch context to compare against.', { color: 'yellow' });
8264
+ return [];
8265
+ }
8226
8266
  try {
8227
- const branchName = await getCurrentBranchName({ git });
8228
- const hasCommits = (await git.raw(['rev-list', '--count', branchName])) !== '0';
8229
- if (!hasCommits) {
8230
- logger?.log('No commits on the current branch.');
8231
- return [];
8232
- }
8233
- let uniqueCommits;
8267
+ let comparisonRef;
8234
8268
  if (comparisonBranch === branchName) {
8235
- // If the comparison branch is the same as the current branch, we compare against the remote.
8236
- uniqueCommits = (await git.raw(['rev-list', `${comparisonRemote}/${comparisonBranch}..${branchName}`]))
8237
- .split('\n')
8238
- .filter(Boolean)
8239
- .reverse();
8269
+ // Same branch as the comparison target compare against the
8270
+ // remote-tracking ref. If the remote (or the ref) does not
8271
+ // exist, fall back to a clean status line rather than throwing.
8272
+ const remoteRef = `${comparisonRemote}/${comparisonBranch}`;
8273
+ if (!(await refExists(git, remoteRef))) {
8274
+ logger?.log(`No "${remoteRef}" ref to compare against — skipping changelog for "${branchName}".`, { color: 'yellow' });
8275
+ return [];
8276
+ }
8277
+ comparisonRef = remoteRef;
8240
8278
  }
8241
8279
  else {
8242
- // Your existing code for different branches
8243
- uniqueCommits = (await git.raw(['rev-list', `${comparisonBranch}..${branchName}`]))
8244
- .split('\n')
8245
- .filter(Boolean)
8246
- .reverse();
8280
+ if (!(await refExists(git, comparisonBranch))) {
8281
+ logger?.log(`Comparison branch "${comparisonBranch}" does not exist — skipping changelog for "${branchName}".`, { color: 'yellow' });
8282
+ return [];
8283
+ }
8284
+ comparisonRef = comparisonBranch;
8247
8285
  }
8248
- logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}"`, {
8249
- color: 'blue',
8250
- });
8286
+ const uniqueCommits = (await git.raw(['rev-list', `${comparisonRef}..${branchName}`]))
8287
+ .split('\n')
8288
+ .filter(Boolean)
8289
+ .reverse();
8290
+ logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}" vs "${comparisonRef}"`, { color: 'blue' });
8251
8291
  const firstCommit = uniqueCommits[0];
8252
8292
  const lastCommit = uniqueCommits[uniqueCommits.length - 1];
8253
8293
  if (!firstCommit || !lastCommit) {
8254
- logger?.log('Unable to determine first and last commit on the current branch', {
8255
- color: 'yellow',
8256
- });
8294
+ // Empty rev-list output is the normal outcome when the branch is
8295
+ // at or behind its baseline. Not an error.
8296
+ logger?.log(`No commits on "${branchName}" ahead of "${comparisonRef}".`);
8257
8297
  return [];
8258
8298
  }
8259
8299
  return await getCommitLogRangeDetails(firstCommit, lastCommit, { git, noMerges: true });
8260
8300
  }
8261
8301
  catch (error) {
8262
8302
  logger?.log('Encountered an error getting commit log from current branch', { color: 'red' });
8303
+ logger?.verbose(error instanceof Error ? error.message : String(error), { color: 'red' });
8263
8304
  }
8264
8305
  return [];
8265
8306
  }
@@ -14327,7 +14368,13 @@ const handler$9 = async (argv, logger) => {
14327
14368
  return `## Diff for ${data.branch}\n\n${diffSummary}`;
14328
14369
  }
14329
14370
  if (!data.commits || data.commits.length === 0) {
14330
- return `## ${data.branch}\n\nNo commits found.`;
14371
+ // Short-circuit with an empty context so the review loop drops
14372
+ // into `noResult` instead of spending an LLM call summarising
14373
+ // "No commits found." into a fake changelog entry. The
14374
+ // upstream helper (getCommitLogCurrentBranch) already logged
14375
+ // the reason (detached HEAD, missing comparison ref, branch at
14376
+ // baseline, etc.) in a friendly status line.
14377
+ return '';
14331
14378
  }
14332
14379
  let result = `## ${data.branch}\n\n`;
14333
14380
  result += data.commits.map(commit => {
@@ -14404,10 +14451,15 @@ const handler$9 = async (argv, logger) => {
14404
14451
  },
14405
14452
  noResult: async () => {
14406
14453
  if (config.range) {
14407
- logger.log(`No commits found in the provided range.`, { color: 'red' });
14454
+ logger.log(`No commits found in the provided range.`, { color: 'yellow' });
14408
14455
  commandExit(0);
14409
14456
  }
14410
- logger.log(`No commits found in the current branch.`, { color: 'red' });
14457
+ // Yellow rather than red — for the no-commits-on-current-branch
14458
+ // case the upstream helper has already explained the reason in
14459
+ // a friendly status line (detached HEAD, no comparison ref,
14460
+ // branch at baseline). This is the trailing summary, not an
14461
+ // error.
14462
+ logger.log(`No commits found in the current branch.`, { color: 'yellow' });
14411
14463
  commandExit(0);
14412
14464
  },
14413
14465
  });
@@ -17892,6 +17944,38 @@ const builder$4 = (yargs) => {
17892
17944
  return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
17893
17945
  };
17894
17946
 
17947
+ /**
17948
+ * Detect whether the repository has any commits yet.
17949
+ *
17950
+ * A "fresh" repo (one created by `git init` with no commits) has an
17951
+ * **unborn HEAD** — the `main` (or configured-default) branch ref
17952
+ * exists symbolically but doesn't point at any object. Any plumbing
17953
+ * command that tries to resolve HEAD (`git log`, `git show`, `git
17954
+ * rev-list`) fails fatally on such a repo with `fatal: your current
17955
+ * branch '<X>' does not have any commits yet`.
17956
+ *
17957
+ * Without an explicit pre-check, callers crash with that raw error
17958
+ * (see {@link ../utils/commandExecutor} — the generic-error path
17959
+ * just prints whatever was thrown). This helper lets a command
17960
+ * short-circuit to a friendly "no commits yet" message instead.
17961
+ *
17962
+ * Implementation uses `git rev-parse --verify HEAD` because it's the
17963
+ * cheapest "does HEAD resolve?" probe — no log walk, no working-tree
17964
+ * scan. Returns `true` when rev-parse rejects (unborn HEAD) and
17965
+ * `false` when it succeeds.
17966
+ *
17967
+ * @returns `true` when HEAD is unborn (no commits); `false` otherwise.
17968
+ */
17969
+ async function isEmptyRepo(git) {
17970
+ try {
17971
+ await git.revparse(['--verify', 'HEAD']);
17972
+ return false;
17973
+ }
17974
+ catch {
17975
+ return true;
17976
+ }
17977
+ }
17978
+
17895
17979
  /**
17896
17980
  * Git LFS pointer parsing + diff summarization (#884).
17897
17981
  *
@@ -18310,6 +18394,19 @@ function buildToggleGraphArgs(argv, fullGraph) {
18310
18394
  return { ...argv, view: argv.view ?? 'compact' };
18311
18395
  }
18312
18396
  async function getLogRows(git, argv, options = {}) {
18397
+ // Unborn HEAD short-circuit. Without this, `git log` on a freshly
18398
+ // `git init`'d repo throws "fatal: your current branch 'main' does
18399
+ // not have any commits yet" — fine when the caller can catch and
18400
+ // translate, painful otherwise (the workstation runtime surfaces it
18401
+ // as "Failed to load commits: fatal: ..." in the status line).
18402
+ //
18403
+ // Returning [] is the natural contract: callers that already render
18404
+ // an empty-history surface (`formatLogInkHistoryEmpty`) get the
18405
+ // right experience automatically; `coco log` retains its own
18406
+ // friendlier message via the handler's isEmptyRepo check.
18407
+ if (await isEmptyRepo(git)) {
18408
+ return [];
18409
+ }
18313
18410
  return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
18314
18411
  }
18315
18412
  async function getCommitDetail(git, commit) {
@@ -18381,6 +18478,7 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18381
18478
  deletions: file.deletions,
18382
18479
  },
18383
18480
  hunks: finalHunks,
18481
+ submoduleChange,
18384
18482
  };
18385
18483
  }
18386
18484
 
@@ -21793,6 +21891,56 @@ function formatLogInkBreadcrumb(viewStack) {
21793
21891
  // they're nested deeper than the root view.
21794
21892
  return `${viewStack.join(' › ')} ← <`;
21795
21893
  }
21894
+ /**
21895
+ * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
21896
+ * for the chrome header. Returns an empty string for a root-only stack
21897
+ * so the header stays compact when nothing has been pushed.
21898
+ *
21899
+ * The trailing `← esc` reminds the user that Esc is the way out — same
21900
+ * shape as the view breadcrumb's `← <` so the two read consistently.
21901
+ * The repo breadcrumb shows in addition to the view breadcrumb when
21902
+ * both stacks are non-trivial; the chrome layer is responsible for
21903
+ * laying them out side by side.
21904
+ *
21905
+ * Examples:
21906
+ * `[root]` → ''
21907
+ * `[coco, vendor/lib]` → 'coco › vendor/lib ← esc'
21908
+ * `[coco, vendor/lib, deep]` → 'coco › vendor/lib › deep ← esc'
21909
+ */
21910
+ function formatLogInkRepoBreadcrumb(repoStack) {
21911
+ if (repoStack.length <= 1) {
21912
+ return '';
21913
+ }
21914
+ return `${repoStack.map((frame) => frame.label).join(' › ')} ← esc`;
21915
+ }
21916
+ /**
21917
+ * Combine the repo-stack and view-stack breadcrumb segments for the
21918
+ * header chrome (#931). Each segment is independently rendered by its
21919
+ * formatter and may be empty; this helper interleaves the leading
21920
+ * spacing so the header builder doesn't have to branch on four cases.
21921
+ *
21922
+ * repoCrumb='' viewCrumb='' → ''
21923
+ * repoCrumb='X' viewCrumb='' → ' X'
21924
+ * repoCrumb='' viewCrumb='Y' → ' Y'
21925
+ * repoCrumb='X' viewCrumb='Y' → ' X Y'
21926
+ *
21927
+ * Two leading spaces match the existing chrome — they separate the
21928
+ * breadcrumb from the trailing repo/branch segment in the title row.
21929
+ * Four spaces between segments give the repo crumb visual breathing
21930
+ * room before the view crumb begins.
21931
+ */
21932
+ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
21933
+ if (repoCrumb && viewCrumb) {
21934
+ return ` ${repoCrumb} ${viewCrumb}`;
21935
+ }
21936
+ if (repoCrumb) {
21937
+ return ` ${repoCrumb}`;
21938
+ }
21939
+ if (viewCrumb) {
21940
+ return ` ${viewCrumb}`;
21941
+ }
21942
+ return '';
21943
+ }
21796
21944
  function getLogInkFooterHints(options) {
21797
21945
  if (options.pendingKey) {
21798
21946
  const continuations = getLogInkChordContinuations(options.pendingKey);
@@ -22547,6 +22695,93 @@ function withPoppedView(state) {
22547
22695
  pendingKey: undefined,
22548
22696
  };
22549
22697
  }
22698
+ /**
22699
+ * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
22700
+ * the active view position into the new frame's `parentReturn` so a
22701
+ * subsequent pop lands the user back where they came from, then
22702
+ * resets the per-frame navigation state (active view, view stack,
22703
+ * row / file / submodule cursors, filter) so the nested frame opens
22704
+ * in a clean slate — the mental equivalent of a fresh `coco ui`
22705
+ * launched against the submodule's working dir.
22706
+ *
22707
+ * Carry-over preferences (sidebar tab, branch / tag sort, palette
22708
+ * recents, inspector tab, diff view mode) are intentionally left
22709
+ * untouched. They're user-level choices that should persist across
22710
+ * frames, the same way they persist across view pushes today.
22711
+ *
22712
+ * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22713
+ * outside the reducer in `app.ts`'s parallel ref structure — this
22714
+ * helper only manages the pure view-model side of the push.
22715
+ */
22716
+ function withPushedRepoFrame(state, payload) {
22717
+ const newFrame = {
22718
+ label: payload.label,
22719
+ workdir: payload.workdir,
22720
+ entryRange: payload.entryRange,
22721
+ parentReturn: {
22722
+ activeView: state.activeView,
22723
+ selectedIndex: state.selectedIndex,
22724
+ selectedFileIndex: state.selectedFileIndex,
22725
+ selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22726
+ filter: state.filter,
22727
+ },
22728
+ };
22729
+ return {
22730
+ ...state,
22731
+ repoStack: [...state.repoStack, newFrame],
22732
+ activeView: 'history',
22733
+ viewStack: ['history'],
22734
+ selectedIndex: 0,
22735
+ selectedFileIndex: 0,
22736
+ selectedSubmoduleIndex: 0,
22737
+ filter: '',
22738
+ filterMode: false,
22739
+ pendingCommitFocused: false,
22740
+ pendingKey: undefined,
22741
+ pendingConfirmationId: undefined,
22742
+ pendingConfirmationPayload: undefined,
22743
+ pendingMutationConfirmation: undefined,
22744
+ };
22745
+ }
22746
+ /**
22747
+ * Pop the top repo frame off `state.repoStack` (#931) and restore
22748
+ * the parent's view position from the captured `parentReturn`. A
22749
+ * no-op when the stack is already at its single root frame so this
22750
+ * action is safe to dispatch from generic input handlers (e.g. the
22751
+ * Esc auto-pop wiring that lands in a follow-up PR).
22752
+ *
22753
+ * The defensive `parentReturn` fallback handles the never-supposed-
22754
+ * to-happen case where a non-root frame somehow has no return state
22755
+ * recorded — drop the frame but leave the user's view position
22756
+ * alone rather than crash mid-session.
22757
+ */
22758
+ function withPoppedRepoFrame(state) {
22759
+ if (state.repoStack.length <= 1) {
22760
+ return { ...state, pendingKey: undefined };
22761
+ }
22762
+ const topFrame = state.repoStack[state.repoStack.length - 1];
22763
+ const ret = topFrame.parentReturn;
22764
+ const repoStack = state.repoStack.slice(0, -1);
22765
+ if (!ret) {
22766
+ return { ...state, repoStack, pendingKey: undefined };
22767
+ }
22768
+ return {
22769
+ ...state,
22770
+ repoStack,
22771
+ activeView: ret.activeView,
22772
+ viewStack: [ret.activeView],
22773
+ selectedIndex: ret.selectedIndex,
22774
+ selectedFileIndex: ret.selectedFileIndex,
22775
+ selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
22776
+ filter: ret.filter,
22777
+ filterMode: false,
22778
+ pendingCommitFocused: false,
22779
+ pendingKey: undefined,
22780
+ pendingConfirmationId: undefined,
22781
+ pendingConfirmationPayload: undefined,
22782
+ pendingMutationConfirmation: undefined,
22783
+ };
22784
+ }
22550
22785
  function withReplacedView(state, value) {
22551
22786
  if (topOfStack(state.viewStack) === value) {
22552
22787
  return { ...state, pendingKey: undefined };
@@ -22691,7 +22926,7 @@ function createLogInkState(rows, options = {}) {
22691
22926
  selectedPullRequestTriageIndex: 0,
22692
22927
  selectedIssueFilter: 'open',
22693
22928
  selectedPullRequestFilter: 'open',
22694
- repoStack: [{ label: options.repoLabel || 'root' }],
22929
+ repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
22695
22930
  branchSort: DEFAULT_BRANCH_SORT_MODE,
22696
22931
  tagSort: DEFAULT_TAG_SORT_MODE,
22697
22932
  paletteFilter: '',
@@ -22704,6 +22939,7 @@ function createLogInkState(rows, options = {}) {
22704
22939
  filterMode: false,
22705
22940
  fullGraph: false,
22706
22941
  showHelp: false,
22942
+ helpScrollOffset: 0,
22707
22943
  showCommandPalette: false,
22708
22944
  workflowActionId: undefined,
22709
22945
  pendingConfirmationId: undefined,
@@ -22711,8 +22947,13 @@ function createLogInkState(rows, options = {}) {
22711
22947
  pendingMutationConfirmation: undefined,
22712
22948
  pendingKey: undefined,
22713
22949
  focus: 'commits',
22714
- sidebarTab: 'status',
22715
- userSidebarTab: 'status',
22950
+ // Default first-time tab is 'branches' — it's the most useful
22951
+ // landing surface in the workstation (current branch + recent
22952
+ // branches with ahead/behind, switch target, etc.). Users who
22953
+ // pick a different tab have their choice persisted per-repo via
22954
+ // sidebarPersistence.ts and won't see this default again.
22955
+ sidebarTab: 'branches',
22956
+ userSidebarTab: 'branches',
22716
22957
  sidebarHeaderFocused: false,
22717
22958
  statusGroupHeaderFocused: false,
22718
22959
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
@@ -22733,6 +22974,15 @@ function getSelectedInkCommit(state) {
22733
22974
  }
22734
22975
  return state.filteredCommits[state.selectedIndex];
22735
22976
  }
22977
+ /**
22978
+ * True when the user has drilled into a submodule (or deeper).
22979
+ * Drives the chrome breadcrumb's display and any future
22980
+ * frame-aware behavior that wants to know "are we in a nested
22981
+ * frame?" without inspecting the stack directly.
22982
+ */
22983
+ function isLogInkNestedRepo(state) {
22984
+ return state.repoStack.length > 1;
22985
+ }
22736
22986
  function applyLogInkAction(state, action) {
22737
22987
  switch (action.type) {
22738
22988
  case 'appendRows':
@@ -23155,6 +23405,14 @@ function applyLogInkAction(state, action) {
23155
23405
  return withPoppedView(state);
23156
23406
  case 'replaceView':
23157
23407
  return withReplacedView(state, action.value);
23408
+ case 'pushRepoFrame':
23409
+ return withPushedRepoFrame(state, {
23410
+ label: action.label,
23411
+ workdir: action.workdir,
23412
+ entryRange: action.entryRange,
23413
+ });
23414
+ case 'popRepoFrame':
23415
+ return withPoppedRepoFrame(state);
23158
23416
  case 'navigateHome': {
23159
23417
  if (state.viewStack.length === 1 && topOfStack(state.viewStack) === HOME_VIEW) {
23160
23418
  return { ...state, pendingKey: undefined };
@@ -23326,6 +23584,7 @@ function applyLogInkAction(state, action) {
23326
23584
  filterMode: !state.filterMode,
23327
23585
  showCommandPalette: false,
23328
23586
  showHelp: false,
23587
+ helpScrollOffset: 0,
23329
23588
  pendingKey: undefined,
23330
23589
  };
23331
23590
  case 'toggleGraph':
@@ -23334,19 +23593,35 @@ function applyLogInkAction(state, action) {
23334
23593
  fullGraph: !state.fullGraph,
23335
23594
  pendingKey: undefined,
23336
23595
  };
23337
- case 'toggleHelp':
23596
+ case 'toggleHelp': {
23597
+ const opening = !state.showHelp;
23338
23598
  return {
23339
23599
  ...state,
23340
- showHelp: !state.showHelp,
23600
+ showHelp: opening,
23601
+ // Reset scroll position when toggling either direction so the
23602
+ // next open always starts at the top — feels more predictable
23603
+ // than picking up where the user last scrolled.
23604
+ helpScrollOffset: 0,
23341
23605
  showCommandPalette: false,
23342
23606
  pendingKey: undefined,
23343
23607
  };
23608
+ }
23609
+ case 'scrollHelp':
23610
+ // No upper-bound clamp here — the renderer caps the offset
23611
+ // against the actual content height at render time. The
23612
+ // reducer just prevents going below 0 so callers can safely
23613
+ // pass negative deltas without us going past the top.
23614
+ return {
23615
+ ...state,
23616
+ helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta),
23617
+ };
23344
23618
  case 'toggleCommandPalette': {
23345
23619
  const opening = !state.showCommandPalette;
23346
23620
  return {
23347
23621
  ...state,
23348
23622
  showCommandPalette: opening,
23349
23623
  showHelp: false,
23624
+ helpScrollOffset: 0,
23350
23625
  // Reset palette interaction state on every open/close so the next
23351
23626
  // session starts from a clean slate.
23352
23627
  paletteFilter: '',
@@ -23993,6 +24268,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
23993
24268
  value: 'open branches / tags / history and press m on the cursored ref',
23994
24269
  })];
23995
24270
  case 'navigateBack':
24271
+ // Mirror the Esc / `<` semantics (#931): drain the frame's view
24272
+ // stack first, then pop the frame itself when nested.
24273
+ if (state.viewStack.length > 1) {
24274
+ return [action({ type: 'popView' })];
24275
+ }
24276
+ if (isLogInkNestedRepo(state)) {
24277
+ return [action({ type: 'popRepoFrame' })];
24278
+ }
23996
24279
  return [action({ type: 'popView' })];
23997
24280
  case 'openSelected': {
23998
24281
  // From history → diff for selected commit; from status → diff for
@@ -24540,8 +24823,37 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24540
24823
  }
24541
24824
  return [];
24542
24825
  }
24543
- if (key.escape && state.showHelp) {
24544
- return [action({ type: 'toggleHelp' })];
24826
+ // Help-overlay key handling. While help is open we intercept ALL
24827
+ // keys here and return before they can fall through to scroll /
24828
+ // focus / navigation logic below. Without this, j/k while help is
24829
+ // open routes into `moveDetailFile`-style handlers, which mutates
24830
+ // focus state (`focus: 'detail'` → `'commits'` or `'sidebar'`) —
24831
+ // exactly the "scroll loses focus" bug.
24832
+ //
24833
+ // Allowed: Esc / ? (close), q (quit), j/k/arrows (scroll), Ctrl-d/u
24834
+ // (half-page). Everything else is swallowed by the trailing
24835
+ // `return []` so a stray keypress can't drop the user into the
24836
+ // wrong surface.
24837
+ if (state.showHelp) {
24838
+ if (key.escape || inputValue === '?') {
24839
+ return [action({ type: 'toggleHelp' })];
24840
+ }
24841
+ if (inputValue === 'q') {
24842
+ return [{ type: 'exit' }];
24843
+ }
24844
+ if (key.downArrow || inputValue === 'j') {
24845
+ return [action({ type: 'scrollHelp', delta: 1 })];
24846
+ }
24847
+ if (key.upArrow || inputValue === 'k') {
24848
+ return [action({ type: 'scrollHelp', delta: -1 })];
24849
+ }
24850
+ if (key.ctrl && inputValue === 'd') {
24851
+ return [action({ type: 'scrollHelp', delta: 10 })];
24852
+ }
24853
+ if (key.ctrl && inputValue === 'u') {
24854
+ return [action({ type: 'scrollHelp', delta: -10 })];
24855
+ }
24856
+ return [];
24545
24857
  }
24546
24858
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
24547
24859
  // BEFORE the generic `popView` so we both clear the wizard state
@@ -24562,6 +24874,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24562
24874
  if (key.escape && state.viewStack.length > 1) {
24563
24875
  return [action({ type: 'popView' })];
24564
24876
  }
24877
+ // #931 — Esc auto-pop. When the user has drilled into a submodule
24878
+ // (nested repo frame) AND they're at the root of that frame's own
24879
+ // view stack, Esc walks back out to the parent repo. Ordered after
24880
+ // the view-stack pop above so Esc still drains a frame's view stack
24881
+ // before popping the frame itself — the user sees a predictable
24882
+ // "back, back, back" path out.
24883
+ if (key.escape && isLogInkNestedRepo(state)) {
24884
+ return [action({ type: 'popRepoFrame' })];
24885
+ }
24565
24886
  if (inputValue === 'q') {
24566
24887
  if (hasUnsavedComposeDraft(state)) {
24567
24888
  return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
@@ -24844,6 +25165,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24844
25165
  return [action({ type: 'toggleGraph' })];
24845
25166
  }
24846
25167
  if (inputValue === '<') {
25168
+ // #931 — `<` is the keymap-driven mirror of Esc auto-pop. When the
25169
+ // view stack has somewhere to go, pop a view; otherwise, if we're
25170
+ // in a nested submodule frame, walk back out to the parent. The
25171
+ // `popView` action is itself a no-op at the root of a frame's
25172
+ // view stack, so this ordering can't double-pop.
25173
+ if (state.viewStack.length > 1) {
25174
+ return [action({ type: 'popView' })];
25175
+ }
25176
+ if (isLogInkNestedRepo(state)) {
25177
+ return [action({ type: 'popRepoFrame' })];
25178
+ }
24847
25179
  return [action({ type: 'popView' })];
24848
25180
  }
24849
25181
  if (inputValue === 'G') {
@@ -25326,6 +25658,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25326
25658
  }
25327
25659
  }
25328
25660
  }
25661
+ // #931 PR 3b — Enter on a submodule file in a commit diff drills into
25662
+ // the submodule's history (the "spawn a coco ui scoped to the
25663
+ // submodule" mental model from the design doc). The runtime decides
25664
+ // whether the cursored file is a drill-in candidate and resolves the
25665
+ // workdir + entryRange ahead of time; the handler here only fires
25666
+ // when that target is populated. Ordered before the generic file-
25667
+ // list Enter handler so the drill-in takes precedence over the
25668
+ // detail-panel diff-refocus path.
25669
+ if (key.return &&
25670
+ state.activeView === 'diff' &&
25671
+ state.diffSource === 'commit' &&
25672
+ context.commitDiffSubmoduleDrillIn) {
25673
+ const target = context.commitDiffSubmoduleDrillIn;
25674
+ return [
25675
+ action({
25676
+ type: 'pushRepoFrame',
25677
+ label: target.label,
25678
+ workdir: target.workdir,
25679
+ entryRange: target.entryRange,
25680
+ }),
25681
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25682
+ ];
25683
+ }
25684
+ // #931 PR 4 / #932 — Enter on a row in the dedicated submodules view
25685
+ // drills into that submodule's history. Same mental model as the
25686
+ // commit-diff drill-in (PR 3b) — pushing a frame is the equivalent
25687
+ // of `cd vendor/lib && coco ui`. No entry range here; the submodules
25688
+ // view doesn't carry diff context, so the frame lands on the
25689
+ // submodule's full history.
25690
+ if (key.return &&
25691
+ isSubmodulesActionTarget(state) &&
25692
+ context.submoduleViewDrillIn) {
25693
+ const target = context.submoduleViewDrillIn;
25694
+ return [
25695
+ action({
25696
+ type: 'pushRepoFrame',
25697
+ label: target.label,
25698
+ workdir: target.workdir,
25699
+ }),
25700
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25701
+ ];
25702
+ }
25329
25703
  if (key.return &&
25330
25704
  state.activeView === 'history' &&
25331
25705
  state.focus === 'commits' &&
@@ -26233,6 +26607,199 @@ function pickSpinnerFrame(tick) {
26233
26607
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
26234
26608
  }
26235
26609
 
26610
+ /**
26611
+ * Build the initial `LogInkContextStatus` for a freshly-created frame
26612
+ * (#931). Every fetched key starts in `'loading'` so surfaces show the
26613
+ * loading hint immediately; `pullRequest` is the exception (#808) —
26614
+ * it's lazy-loaded on entry to the PR view, so we seed it `'idle'`
26615
+ * instead of leaving it stuck as a permanent "loading" flag in the
26616
+ * chrome.
26617
+ *
26618
+ * Extracted so the root runtime (built at boot inside `LogInkApp`) and
26619
+ * the per-frame factory below share one canonical seed. The status
26620
+ * surfaces depend on the exact `'pullRequest' = 'idle'` initialization
26621
+ * to avoid spurious loading hints; locking it down in one helper means
26622
+ * the two code paths can't drift.
26623
+ */
26624
+ function createInitialContextStatus() {
26625
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
26626
+ }
26627
+ /**
26628
+ * Factory that builds a fresh `RepoFrameRuntime` for a newly-pushed
26629
+ * frame (#931). The frame's `workdir` (set by the push action) drives
26630
+ * which working tree the `SimpleGit` instance binds against:
26631
+ *
26632
+ * - **Has workdir** → `simpleGit(workdir)`. Production case for any
26633
+ * nested submodule frame.
26634
+ * - **No workdir** → falls back to `rootGit`. Defensive: only the
26635
+ * root frame is expected to lack a workdir, and the root frame's
26636
+ * runtime is built directly from `rootGit` in `LogInkApp`'s state
26637
+ * initializer — this fallback only kicks in if a future push path
26638
+ * forgets to pass `workdir`. Binding to the root keeps the session
26639
+ * functional (the user still sees data) at the cost of the frame
26640
+ * being a duplicate of the root.
26641
+ *
26642
+ * `context` starts empty; `contextStatus` starts in the same initial
26643
+ * "loading + pullRequest idle" shape the root frame seeds with. The
26644
+ * sync effect in `LogInkApp` is responsible for kicking off the
26645
+ * per-key context loads against the new frame's `git`; we don't do
26646
+ * that here so the factory stays pure and unit-testable without a
26647
+ * real repo on disk.
26648
+ */
26649
+ function createRepoFrameRuntime(frame, rootGit) {
26650
+ return {
26651
+ git: frame.workdir ? simpleGit(frame.workdir) : rootGit,
26652
+ context: {},
26653
+ contextStatus: createInitialContextStatus(),
26654
+ };
26655
+ }
26656
+
26657
+ /**
26658
+ * Pure resolver: given the cursored file + the active frame's
26659
+ * submodule overview + repo root, decide whether a commit-diff Enter
26660
+ * keystroke should drill into a submodule and, if so, what payload
26661
+ * the `pushRepoFrame` action should carry.
26662
+ *
26663
+ * Returns undefined when:
26664
+ * - We don't know the active repo root yet (boot still in flight).
26665
+ * - The file's path doesn't correspond to a registered submodule.
26666
+ * - The submodule overview hasn't loaded yet for the active frame.
26667
+ *
26668
+ * The `submoduleChange` on the file preview is the source of truth
26669
+ * for the entry range; we never need to re-run the diff to populate
26670
+ * the (oldSha, newSha) pair.
26671
+ */
26672
+ function resolveCommitDiffDrillInTarget(args) {
26673
+ const { selectedFile, submodules, activeRepoRoot } = args;
26674
+ if (!activeRepoRoot)
26675
+ return undefined;
26676
+ if (!submodules || !submodules.hasSubmodules)
26677
+ return undefined;
26678
+ const entry = findSubmoduleByPath(submodules, selectedFile.path);
26679
+ if (!entry)
26680
+ return undefined;
26681
+ return {
26682
+ label: entry.name,
26683
+ workdir: join$1(activeRepoRoot, entry.path),
26684
+ entryRange: deriveEntryRange(selectedFile.submoduleChange),
26685
+ };
26686
+ }
26687
+ /**
26688
+ * Convert the structured `SubmoduleChange` (from `extractSubmoduleChange`)
26689
+ * into the `entryRange` shape `LogInkRepoFrame` carries. Modified
26690
+ * submodules surface both shas; added / removed surface only one,
26691
+ * which isn't enough to scope a history range — those cases return
26692
+ * undefined and the frame lands on the submodule's full history.
26693
+ */
26694
+ function deriveEntryRange(change) {
26695
+ if (!change)
26696
+ return undefined;
26697
+ if (change.kind === 'modified') {
26698
+ return { oldSha: change.before, newSha: change.after };
26699
+ }
26700
+ return undefined;
26701
+ }
26702
+ /**
26703
+ * Pure resolver for the submodules-view drill-in (#931 PR 4 / #932).
26704
+ * Given the cursored row index + the submodule overview + the active
26705
+ * frame's repo root, build the `pushRepoFrame` payload Enter should
26706
+ * dispatch. Returns undefined when:
26707
+ *
26708
+ * - The active repo root hasn't loaded yet.
26709
+ * - The submodule overview hasn't loaded (or is empty).
26710
+ * - The cursor is past the end of the entries (race between a
26711
+ * refresh that removed a submodule and a key press still in
26712
+ * flight against the old length).
26713
+ * - The cursored entry has no `path` recorded. The `.gitmodules`
26714
+ * parser already filters these out upstream, but the resolver
26715
+ * defends against it so the cursor can't yank the user into a
26716
+ * workdir-less frame.
26717
+ */
26718
+ function resolveSubmoduleViewDrillInTarget(args) {
26719
+ const { selectedIndex, submodules, activeRepoRoot } = args;
26720
+ if (!activeRepoRoot)
26721
+ return undefined;
26722
+ if (!submodules || !submodules.hasSubmodules)
26723
+ return undefined;
26724
+ const entry = submodules.entries[selectedIndex];
26725
+ if (!entry || !entry.path)
26726
+ return undefined;
26727
+ return {
26728
+ label: entry.name,
26729
+ workdir: join$1(activeRepoRoot, entry.path),
26730
+ };
26731
+ }
26732
+
26733
+ /**
26734
+ * Reconcile the per-frame runtime list against the current view-model
26735
+ * stack. Three cases:
26736
+ *
26737
+ * - **No change** — same length, returns `prev` unchanged so React
26738
+ * reference equality skips downstream re-renders.
26739
+ * - **Pop** — stack shrunk, returns `prev.slice(0, stack.length)`.
26740
+ * The dropped runtimes are released to the GC; the surviving
26741
+ * runtimes (root + any intermediate frames) keep their cached
26742
+ * `git` + `context` so a re-push lands on warm state.
26743
+ * - **Push** — stack grew, builds a fresh runtime via the supplied
26744
+ * `createRuntime(frame, depth)` factory for each newly-deeper
26745
+ * frame. The caller is responsible for the factory's content;
26746
+ * this module never imports `simple-git` or `loadLogInkContext`
26747
+ * directly so it stays unit-testable without a real repo on disk.
26748
+ *
26749
+ * Returns `newlyAddedIndices` so the caller's effect knows which
26750
+ * frames need their initial context fetch kicked off. On a no-op or
26751
+ * pop, the list is empty.
26752
+ *
26753
+ * The reducer's `pushRepoFrame` / `popRepoFrame` actions are the only
26754
+ * things that mutate `state.repoStack`; both are monotone — push
26755
+ * appends one, pop drops one — so this helper never needs to handle
26756
+ * "frame at index i changed identity in place." If that invariant ever
26757
+ * loosens, this helper should error rather than silently mis-bind a
26758
+ * `SimpleGit` to the wrong working directory.
26759
+ */
26760
+ function syncRepoStackRuntimes(prev, stack, createRuntime) {
26761
+ if (stack.length < prev.length) {
26762
+ return { runtimes: prev.slice(0, stack.length), newlyAddedIndices: [] };
26763
+ }
26764
+ if (stack.length === prev.length) {
26765
+ return { runtimes: prev, newlyAddedIndices: [] };
26766
+ }
26767
+ const next = prev.slice();
26768
+ const newlyAddedIndices = [];
26769
+ for (let i = prev.length; i < stack.length; i += 1) {
26770
+ next.push(createRuntime(stack[i], i));
26771
+ newlyAddedIndices.push(i);
26772
+ }
26773
+ return { runtimes: next, newlyAddedIndices };
26774
+ }
26775
+ /**
26776
+ * Top-of-stack runtime — the one every active surface, loader, and
26777
+ * action target reads from. Undefined when the runtime list is empty
26778
+ * (which production code never produces — `createLogInkState` always
26779
+ * seeds a root frame, so the corresponding root runtime is built on
26780
+ * mount and the array is non-empty for the lifetime of the session).
26781
+ */
26782
+ function getActiveRepoFrameRuntime(runtimes) {
26783
+ return runtimes[runtimes.length - 1];
26784
+ }
26785
+ /**
26786
+ * Immutably update one frame's runtime entry. Used by the app shell's
26787
+ * loader effects when a frame's `context` or `contextStatus` changes
26788
+ * — replacing the entry in place lets React's referential equality
26789
+ * skip re-renders on unrelated frames.
26790
+ *
26791
+ * Out-of-range indices are no-ops (return `prev` unchanged) so the
26792
+ * caller doesn't have to guard against race-y stack changes between
26793
+ * the load kickoff and the load-complete callback.
26794
+ */
26795
+ function updateRepoFrameRuntime(runtimes, index, updater) {
26796
+ if (index < 0 || index >= runtimes.length)
26797
+ return runtimes;
26798
+ const next = runtimes.slice();
26799
+ next[index] = updater(next[index]);
26800
+ return next;
26801
+ }
26802
+
26236
26803
  /**
26237
26804
  * Persist the user's preferred diff view mode (unified vs side-by-side
26238
26805
  * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
@@ -28806,7 +29373,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
28806
29373
  ? ' loading commits'
28807
29374
  : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
28808
29375
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
28809
- const view = breadcrumb ? ` ${breadcrumb}` : '';
29376
+ const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
29377
+ // Repo breadcrumb (when nested) comes first so the user sees which
29378
+ // submodule they're in at a glance, then the view breadcrumb (when
29379
+ // pushed deeper than the root view). The truncate fallback in the
29380
+ // title row still applies — when both fight for space, the ellipsis
29381
+ // lands at the end of whichever segment overflows.
29382
+ const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
28810
29383
  // Mode indicator (P2.2) — surfaces the current input mode so users
28811
29384
  // never wonder why `q` doesn't quit while they're editing or filtering.
28812
29385
  const mode = state.commitCompose.editing
@@ -30454,30 +31027,52 @@ function filterChippedRefs(refs, chip) {
30454
31027
  return true;
30455
31028
  });
30456
31029
  }
30457
- function getBranchTipChip(refs) {
31030
+ /**
31031
+ * `remoteNames` lets the caller pass the repository's actual remote
31032
+ * names (e.g. `['origin', 'upstream']`) so refs are classified by
31033
+ * remote-prefix rather than by "contains a slash". Without it a local
31034
+ * feature branch like `feat/x` looks identical to a remote-tracking
31035
+ * `origin/x` and gets the wrong colour. When the list is omitted the
31036
+ * function falls back to the legacy slash-as-remote heuristic — the
31037
+ * sensible default before branch data has loaded and a back-compat
31038
+ * affordance for callers that have no remote data to hand.
31039
+ */
31040
+ function getBranchTipChip(refs, remoteNames) {
31041
+ // Empty list is treated the same as omitted: branch data hasn't
31042
+ // loaded yet, so we don't have ground truth and the legacy "slash =
31043
+ // remote" heuristic is the best guess for first paint.
31044
+ const hasRemoteList = Array.isArray(remoteNames) && remoteNames.length > 0;
31045
+ const isRemoteRef = (ref) => {
31046
+ if (!ref.includes('/'))
31047
+ return false;
31048
+ if (!hasRemoteList)
31049
+ return true;
31050
+ return remoteNames.some((remote) => remote && ref.startsWith(`${remote}/`));
31051
+ };
30458
31052
  for (const ref of refs) {
30459
31053
  if (ref.startsWith('HEAD -> ')) {
30460
31054
  const name = ref.slice('HEAD -> '.length).trim();
30461
31055
  if (name)
30462
- return { name, isHead: true };
31056
+ return { name, isHead: true, kind: 'head' };
30463
31057
  }
30464
31058
  }
30465
31059
  for (const ref of refs) {
30466
31060
  if (ref === 'HEAD' ||
30467
31061
  ref.startsWith('HEAD -> ') ||
30468
31062
  ref.startsWith('tag: ') ||
30469
- ref.includes('/')) {
31063
+ isRemoteRef(ref)) {
30470
31064
  continue;
30471
31065
  }
30472
- if (ref.trim())
30473
- return { name: ref.trim(), isHead: false };
31066
+ if (ref.trim()) {
31067
+ return { name: ref.trim(), isHead: false, kind: 'local' };
31068
+ }
30474
31069
  }
30475
31070
  for (const ref of refs) {
30476
31071
  if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
30477
31072
  continue;
30478
31073
  }
30479
- if (ref.includes('/') && ref.trim()) {
30480
- return { name: ref.trim(), isHead: false };
31074
+ if (isRemoteRef(ref) && ref.trim()) {
31075
+ return { name: ref.trim(), isHead: false, kind: 'remote' };
30481
31076
  }
30482
31077
  }
30483
31078
  return undefined;
@@ -31240,8 +31835,12 @@ const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
31240
31835
  * descriptor so the caller can pass it to `filterChippedRefs` and
31241
31836
  * avoid emitting the same branch a second time in the trailing list.
31242
31837
  */
31243
- function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31244
- const chip = getBranchTipChip(commit.refs);
31838
+ // Exported for unit / snapshot testing in branchTipChipRender.test.ts.
31839
+ // The function isn't part of the public surface of this module — the
31840
+ // rest of the file is internal — but the chip-rendering logic is
31841
+ // dense enough that structural snapshot tests pay for themselves.
31842
+ function renderBranchTipChip(h, Text, commit, theme, key, selected, remoteNames) {
31843
+ const chip = getBranchTipChip(commit.refs, remoteNames);
31245
31844
  if (!chip)
31246
31845
  return { node: null, width: 0, chip };
31247
31846
  const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
@@ -31262,7 +31861,23 @@ function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31262
31861
  chip,
31263
31862
  };
31264
31863
  }
31265
- const accent = chip.isHead ? theme.colors.success : theme.colors.info;
31864
+ // Three-way colour assignment matches `BranchTipChipKind`:
31865
+ //
31866
+ // - HEAD → success (the user's current branch — bright green)
31867
+ // - local → info (other local branches — calm blue)
31868
+ // - remote → warning (remote-tracking refs like origin/main —
31869
+ // distinct so "where is upstream?" reads at a glance)
31870
+ //
31871
+ // Without the remote/local split, a chip on `origin/main` looked
31872
+ // identical to a local-branch chip, so users couldn't tell from the
31873
+ // commit list where their upstream actually pointed. The warning hue
31874
+ // (typically a muted yellow / orange) is purposeful: not alarming,
31875
+ // but visibly different from the local blue.
31876
+ const accent = chip.kind === 'head'
31877
+ ? theme.colors.success
31878
+ : chip.kind === 'remote'
31879
+ ? theme.colors.warning
31880
+ : theme.colors.info;
31266
31881
  return {
31267
31882
  node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
31268
31883
  width: cellWidth(body) + 1,
@@ -31352,7 +31967,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
31352
31967
  * Truncation is per-segment so the variable-length message field gets
31353
31968
  * the leftover budget after fixed segments are accounted for.
31354
31969
  */
31355
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
31970
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false, remoteNames) {
31356
31971
  // Total cells available to the row content. Earlier revisions used a
31357
31972
  // hardcoded 140 here, which let row content overflow whenever the
31358
31973
  // panel was narrower than that — Ink would wrap onto a second visual
@@ -31369,7 +31984,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31369
31984
  // out whatever the chip already shows so the row doesn't print
31370
31985
  // `[main] feat: x [HEAD -> main]` with the same info on both ends.
31371
31986
  const chip = fullGraph
31372
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
31987
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected, remoteNames)
31373
31988
  : { node: null, width: 0, chip: undefined };
31374
31989
  const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31375
31990
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
@@ -31431,7 +32046,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31431
32046
  * line stays dim regardless of selection so it doesn't pull the eye
31432
32047
  * away from the subject.
31433
32048
  */
31434
- function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
32049
+ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false, remoteNames) {
31435
32050
  const totalWidth = Math.max(20, panelWidth - 4);
31436
32051
  const accent = theme.noColor ? undefined : theme.colors.accent;
31437
32052
  const muted = theme.noColor ? undefined : theme.colors.muted;
@@ -31442,7 +32057,7 @@ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth,
31442
32057
  // same way as the single-line variant, but only in full-graph mode.
31443
32058
  const recentMarkerWidth = isRecent ? 2 : 0;
31444
32059
  const chip = fullGraph
31445
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
32060
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected, remoteNames)
31446
32061
  : { node: null, width: 0, chip: undefined };
31447
32062
  const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
31448
32063
  const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
@@ -31517,6 +32132,17 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31517
32132
  const { Box, Text } = components;
31518
32133
  const focused = state.focus === 'commits';
31519
32134
  const worktree = context.worktree;
32135
+ // Distinct remote names seen across the repo's remote-tracking
32136
+ // branches — `['origin']` for a typical fork, `['origin', 'upstream']`
32137
+ // when the user has both. Used to classify branch-tip chips so a
32138
+ // slashed local branch like `feat/x` doesn't get mis-coloured as
32139
+ // remote. When branch data hasn't loaded yet, `undefined` makes the
32140
+ // chip helper fall back to the legacy slash-based heuristic.
32141
+ const remoteNames = context.branches?.remoteBranches
32142
+ ? Array.from(new Set(context.branches.remoteBranches
32143
+ .map((branch) => branch.remote)
32144
+ .filter((remote) => Boolean(remote))))
32145
+ : undefined;
31520
32146
  // Set of just-landed commit hashes for the "new commit" marker.
31521
32147
  // Populated for ~5s after a split-apply or other commit-creating
31522
32148
  // operation; auto-cleared by the runtime so it doesn't linger.
@@ -31625,9 +32251,9 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31625
32251
  }, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
31626
32252
  }
31627
32253
  if (rowMode === 'stacked') {
31628
- 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));
32254
+ 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);
31629
32255
  }
31630
- 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));
32256
+ 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);
31631
32257
  }));
31632
32258
  }
31633
32259
 
@@ -31783,20 +32409,38 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
31783
32409
  }
31784
32410
  function renderHelpPanel(h, components, state, width, theme, focused) {
31785
32411
  const { Box, Text } = components;
31786
- const children = [
31787
- h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
31788
- ];
32412
+ // Build the full list of body rows (everything below the title).
32413
+ // Splitting into title + body lets us window the body by
32414
+ // `state.helpScrollOffset` while keeping the title pinned.
32415
+ const body = [];
31789
32416
  const sections = getLogInkHelpSections({
31790
32417
  activeView: state.activeView,
31791
32418
  focus: state.focus,
31792
32419
  });
31793
32420
  for (const section of sections) {
31794
- children.push(h(Text, { key: `${section.title}-spacer` }, ''));
31795
- children.push(h(Text, { bold: true, key: section.title }, section.title));
32421
+ body.push(h(Text, { key: `${section.title}-spacer` }, ''));
32422
+ body.push(h(Text, { bold: true, key: section.title }, section.title));
31796
32423
  section.bindings.forEach((binding) => {
31797
- children.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
32424
+ body.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
31798
32425
  });
31799
32426
  }
32427
+ // Clamp the offset against actual content length. The reducer
32428
+ // only floor-clamps at 0; here we ceiling-clamp so j past EOF
32429
+ // sticks at the last row rather than scrolling into emptiness.
32430
+ // Reserve one row at the bottom so the user can always see the
32431
+ // tail of the last section.
32432
+ const maxOffset = Math.max(0, body.length - 1);
32433
+ const offset = Math.min(state.helpScrollOffset, maxOffset);
32434
+ const children = [
32435
+ h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
32436
+ ];
32437
+ // Visual hint that there's content scrolled above. The dim style
32438
+ // matches the rest of the chrome's "metadata" voice and avoids
32439
+ // stealing attention from the bindings themselves.
32440
+ if (offset > 0) {
32441
+ children.push(h(Text, { key: 'more-above', dimColor: true }, '↑ more above'));
32442
+ }
32443
+ children.push(...body.slice(offset));
31800
32444
  return h(Box, {
31801
32445
  borderColor: focusBorderColor(theme, focused),
31802
32446
  borderStyle: theme.borderStyle,
@@ -34532,7 +35176,7 @@ function enrichFilterActionWithRectification(action, state, context) {
34532
35176
  }
34533
35177
  }
34534
35178
  function LogInkApp(deps) {
34535
- const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
35179
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
34536
35180
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
34537
35181
  const h = React.createElement;
34538
35182
  const { exit } = useApp();
@@ -34563,16 +35207,98 @@ function LogInkApp(deps) {
34563
35207
  // immediately while the chrome still flags the refresh.
34564
35208
  bootLoading: Boolean(loadRows),
34565
35209
  }));
34566
- const [context, setContext] = React.useState({});
34567
- const [contextStatus, setContextStatus] = React.useState(() => {
34568
- // Boot starts every fetched key in 'loading' so the surfaces show
34569
- // their loading hints immediately. `pullRequest` is the exception
34570
- // (#808) it isn't part of the boot fetch entries; it lazy-loads
34571
- // when the user enters the PR view. Marking it 'idle' avoids a
34572
- // permanent "loading" flag in the chrome and lets the dedicated
34573
- // PR view's own load effect drive its loading state.
34574
- return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
34575
- });
35210
+ // Nested-repo runtime stack (#931). Each frame holds the live
35211
+ // `SimpleGit`, the loaded `LogInkContext`, and the per-key load
35212
+ // status the chrome reads. The active (top-of-stack) entry drives
35213
+ // every loader and surface; popping a frame restores the parent's
35214
+ // cached entry so a drill-in / drill-out round trip doesn't re-pay
35215
+ // the context load cost. Seeded with a single root runtime against
35216
+ // the cwd `coco ui` was launched in.
35217
+ const [runtimes, setRuntimes] = React.useState(() => [{
35218
+ git: rootGit,
35219
+ context: {},
35220
+ contextStatus: createInitialContextStatus(),
35221
+ }]);
35222
+ // Sync `runtimes` against the view-model stack on every push / pop.
35223
+ // The sync is monotone — push appends a new runtime via the factory,
35224
+ // pop slices off the top runtime; the parent's cached state survives.
35225
+ // The factory is wrapped to capture `rootGit` so a defensively-pushed
35226
+ // frame without a workdir still has a working `SimpleGit` bound.
35227
+ React.useEffect(() => {
35228
+ setRuntimes((prev) => {
35229
+ const { runtimes: next } = syncRepoStackRuntimes(prev, state.repoStack, (frame) => createRepoFrameRuntime(frame, rootGit));
35230
+ return next;
35231
+ });
35232
+ }, [state.repoStack, rootGit]);
35233
+ // Active-frame projection (#931). `git`, `context`, `contextStatus`
35234
+ // — every existing closure / effect / surface reads these names; the
35235
+ // only thing this PR changes is where they come from. When the user
35236
+ // drills into a submodule, the top-of-stack runtime swaps, every
35237
+ // dep array that lists `git` re-fires, and the loaders refetch
35238
+ // against the submodule's working tree.
35239
+ const activeRuntime = getActiveRepoFrameRuntime(runtimes) ?? {
35240
+ git: rootGit,
35241
+ context: {},
35242
+ contextStatus: createInitialContextStatus(),
35243
+ };
35244
+ const git = activeRuntime.git;
35245
+ const context = activeRuntime.context;
35246
+ const contextStatus = activeRuntime.contextStatus;
35247
+ // Wrappers that delegate to the active frame's runtime entry so the
35248
+ // existing call sites stay byte-identical. Support both function-
35249
+ // updater and value-updater forms (the codebase uses both).
35250
+ const setContext = React.useCallback((arg) => {
35251
+ setRuntimes((prev) => {
35252
+ const depth = prev.length - 1;
35253
+ if (depth < 0)
35254
+ return prev;
35255
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35256
+ ...frame,
35257
+ context: typeof arg === 'function'
35258
+ ? arg(frame.context)
35259
+ : arg,
35260
+ }));
35261
+ });
35262
+ }, []);
35263
+ const setContextStatus = React.useCallback((arg) => {
35264
+ setRuntimes((prev) => {
35265
+ const depth = prev.length - 1;
35266
+ if (depth < 0)
35267
+ return prev;
35268
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35269
+ ...frame,
35270
+ contextStatus: typeof arg === 'function'
35271
+ ? arg(frame.contextStatus)
35272
+ : arg,
35273
+ }));
35274
+ });
35275
+ }, []);
35276
+ // #931 PR 3b — Absolute repo root for the active frame's `git`.
35277
+ // Resolved asynchronously after every `git` swap (push / pop /
35278
+ // boot) so the commit-diff drill-in helper can construct absolute
35279
+ // workdirs for submodule paths recorded in `.gitmodules` (which
35280
+ // are repo-relative). Undefined during the brief moment between
35281
+ // git swap and the revparse callback resolving.
35282
+ const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
35283
+ React.useEffect(() => {
35284
+ let cancelled = false;
35285
+ void (async () => {
35286
+ try {
35287
+ const root = (await git.revparse(['--show-toplevel'])).trim();
35288
+ if (!cancelled && root) {
35289
+ setActiveRepoRoot(root);
35290
+ }
35291
+ }
35292
+ catch {
35293
+ if (!cancelled) {
35294
+ setActiveRepoRoot(undefined);
35295
+ }
35296
+ }
35297
+ })();
35298
+ return () => {
35299
+ cancelled = true;
35300
+ };
35301
+ }, [git]);
34576
35302
  const [detail, setDetail] = React.useState(undefined);
34577
35303
  const [detailLoading, setDetailLoading] = React.useState(false);
34578
35304
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -35092,9 +35818,26 @@ function LogInkApp(deps) {
35092
35818
  selectedWorktreeFile?.worktreeStatus,
35093
35819
  state.activeView,
35094
35820
  ]);
35821
+ // #931 PR 5 — Cache-aware boot load. The frame's `git` instance is
35822
+ // the dep that drives this effect; on push, the new frame's runtime
35823
+ // starts every key in `'loading'` and we fetch fresh. On pop, the
35824
+ // parent's runtime carries cached context across the drill-out cycle
35825
+ // (`'ready'` for already-loaded keys), and the per-key gate below
35826
+ // skips the fetch so the user's drill-out is instant + flicker-free.
35827
+ //
35828
+ // `contextStatusRef` reads the latest status without putting
35829
+ // `contextStatus` in the effect deps — including it would re-fire
35830
+ // the effect on every per-key 'ready' write the effect itself
35831
+ // produces, causing duplicate in-flight fetches for not-yet-completed
35832
+ // keys. The ref pattern gives us "read latest" semantics with the
35833
+ // effect still gated on git swaps only.
35834
+ const contextStatusRef = React.useRef(contextStatus);
35835
+ contextStatusRef.current = contextStatus;
35095
35836
  React.useEffect(() => {
35096
35837
  let active = true;
35097
35838
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
35839
+ if (contextStatusRef.current[key] === 'ready')
35840
+ return;
35098
35841
  void load().then((value) => {
35099
35842
  if (!active) {
35100
35843
  return;
@@ -37509,6 +38252,37 @@ function LogInkApp(deps) {
37509
38252
  commitDiffSelectedSha: state.diffSource === 'commit'
37510
38253
  ? selected?.hash
37511
38254
  : undefined,
38255
+ // #931 PR 3b — Submodule drill-in target for the cursored file
38256
+ // in a commit diff. Resolved per-render so the Enter handler in
38257
+ // `inkInput.ts` doesn't have to re-walk the submodule overview;
38258
+ // undefined whenever the cursored file isn't a registered
38259
+ // submodule (or the overview / repo root haven't loaded yet).
38260
+ commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
38261
+ ? resolveCommitDiffDrillInTarget({
38262
+ selectedFile: {
38263
+ path: selectedDetailFile.path,
38264
+ submoduleChange: filePreview?.path === selectedDetailFile.path
38265
+ ? filePreview.submoduleChange
38266
+ : undefined,
38267
+ },
38268
+ submodules: context.submodules,
38269
+ activeRepoRoot,
38270
+ })
38271
+ : undefined,
38272
+ // #931 PR 4 / #932 — Submodule drill-in target for the cursored
38273
+ // row in the dedicated submodules view. Resolved per-render so
38274
+ // the Enter handler in `inkInput.ts` doesn't have to re-walk the
38275
+ // submodule overview. Gated on `activeView === 'submodules'` so
38276
+ // a stale resolution from a different view can't accidentally
38277
+ // fire — the runtime only ever populates it when the user is
38278
+ // actually on the view.
38279
+ submoduleViewDrillIn: state.activeView === 'submodules'
38280
+ ? resolveSubmoduleViewDrillInTarget({
38281
+ selectedIndex: state.selectedSubmoduleIndex,
38282
+ submodules: context.submodules,
38283
+ activeRepoRoot,
38284
+ })
38285
+ : undefined,
37512
38286
  worktreeDirty,
37513
38287
  conflictFileCount: context.operation?.conflictedFiles.length,
37514
38288
  conflictSelectedPath: (() => {
@@ -38257,6 +39031,27 @@ function formatCommitDetail(detail, format) {
38257
39031
  ].join('\n');
38258
39032
  }
38259
39033
 
39034
+ /**
39035
+ * Friendly empty-repo message for the non-interactive log path.
39036
+ *
39037
+ * In `--json` mode we emit an empty array so machine consumers see a
39038
+ * well-defined "no commits" result without a parse error. In table
39039
+ * mode we print a human one-liner that names the next-step commands
39040
+ * the user is likely after. Either way we exit 0 — "no commits" is
39041
+ * a valid repo state, not a failure.
39042
+ */
39043
+ function formatEmptyRepoResult(format) {
39044
+ if (format === 'json') {
39045
+ return '[]';
39046
+ }
39047
+ return [
39048
+ "No commits yet — this looks like a fresh `git init`'d repo.",
39049
+ '',
39050
+ 'Get started:',
39051
+ ' • `coco commit` to draft your first commit message with AI',
39052
+ ' • `git commit -m "chore: initial commit"` to commit by hand',
39053
+ ].join('\n');
39054
+ }
38260
39055
  const handler$3 = async (argv) => {
38261
39056
  // `--repo <dir>` (alias `--cwd`) — apply the global flag via the
38262
39057
  // shared helper. After this returns, `process.cwd()` and the git
@@ -38272,6 +39067,22 @@ const handler$3 = async (argv) => {
38272
39067
  });
38273
39068
  return;
38274
39069
  }
39070
+ // Empty-repo short-circuit. Without this, the underlying `git log`
39071
+ // crashes the command and the user sees a raw "fatal: your current
39072
+ // branch 'main' does not have any commits yet" + a generic "Failed
39073
+ // to execute command" banner. We catch the unborn-HEAD state and
39074
+ // emit a friendly next-step hint (or an empty array in JSON mode)
39075
+ // and exit 0 — "no commits" is a valid repo state, not an error.
39076
+ //
39077
+ // Only applies to the non-interactive path: the TUI runtime gets
39078
+ // its own empty-state rendering inside the workstation.
39079
+ if (!argv.interactive && (await isEmptyRepo(git))) {
39080
+ await handleResult({
39081
+ result: formatEmptyRepoResult(format),
39082
+ mode: 'stdout',
39083
+ });
39084
+ return;
39085
+ }
38275
39086
  // Interactive path defers the commit log fetch into the runtime
38276
39087
  // (#808) so the TUI mounts immediately with a "Loading commits…"
38277
39088
  // placeholder. The non-interactive (stdout) path still needs rows