git-coco 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.esm.mjs +446 -101
  2. package/dist/index.js +446 -101
  3. package/package.json +3 -3
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.40.0";
81
+ const BUILD_VERSION = "0.41.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -2152,11 +2152,21 @@ function formatAuthenticationError(error, logger) {
2152
2152
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2153
2153
  }
2154
2154
  /**
2155
- * Formats a generic error
2155
+ * Formats a generic error.
2156
+ *
2157
+ * The error message prints unconditionally (was previously gated behind
2158
+ * `--verbose`, which left users staring at a "Failed to execute command"
2159
+ * line with no actionable detail when something crashed). The full stack
2160
+ * trace stays under `logger.verbose` so plain output stays focused on the
2161
+ * one-line cause; users running into something they can't diagnose can opt
2162
+ * in with `--verbose` for the trace.
2156
2163
  */
2157
2164
  function formatGenericError(error, logger) {
2158
2165
  logger.log('\nFailed to execute command', { color: 'yellow' });
2159
- logger.verbose(`\nError: "${error.message}"`, { color: 'red' });
2166
+ logger.log(`\nError: ${error.message}`, { color: 'red' });
2167
+ if (error.stack) {
2168
+ logger.verbose(`\n${error.stack}`, { color: 'gray' });
2169
+ }
2160
2170
  }
2161
2171
  function commandExecutor(handler) {
2162
2172
  return async (argv) => {
@@ -16328,6 +16338,11 @@ function replaceRows(state, rows) {
16328
16338
  selectedFileIndex: 0,
16329
16339
  pendingCommitFocused: false,
16330
16340
  pendingKey: undefined,
16341
+ // Rows just landed — clear the boot-loading flag so the history
16342
+ // surface drops the "Loading commits…" placeholder. Safe to clear
16343
+ // unconditionally because `replaceRows` only fires after a real
16344
+ // git log returns.
16345
+ bootLoading: false,
16331
16346
  };
16332
16347
  }
16333
16348
  function appendRows(state, rows) {
@@ -16418,6 +16433,7 @@ function createLogInkState(rows, options = {}) {
16418
16433
  diffViewMode: 'unified',
16419
16434
  inspectorTab: 'inspector',
16420
16435
  inspectorActionIndex: 0,
16436
+ bootLoading: options.bootLoading ?? false,
16421
16437
  };
16422
16438
  }
16423
16439
  function getSelectedInkCommit(state) {
@@ -16619,6 +16635,12 @@ function applyLogInkAction(state, action) {
16619
16635
  inspectorActionIndex: 0,
16620
16636
  pendingKey: undefined,
16621
16637
  };
16638
+ case 'setBootLoading':
16639
+ return {
16640
+ ...state,
16641
+ bootLoading: action.value,
16642
+ pendingKey: undefined,
16643
+ };
16622
16644
  case 'moveTag':
16623
16645
  return {
16624
16646
  ...state,
@@ -17957,6 +17979,18 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17957
17979
  if (key.rightArrow && state.focus === 'sidebar') {
17958
17980
  return [action({ type: 'nextSidebarTab' })];
17959
17981
  }
17982
+ // ←/→ on the inspector switch between the [Inspector] / [Actions]
17983
+ // tabs, mirroring the sidebar's left/right tab semantics. `[` and
17984
+ // `]` still work as keyboard alternatives, but the visible hint in
17985
+ // the inspector chrome shows ←/→ because the bracketed `[/]`
17986
+ // notation reads as "press the / key" — which is the global filter
17987
+ // trigger and was making users think the binding was busted.
17988
+ if (key.leftArrow && state.focus === 'detail') {
17989
+ return [action({ type: 'setInspectorTab', value: 'inspector' })];
17990
+ }
17991
+ if (key.rightArrow && state.focus === 'detail') {
17992
+ return [action({ type: 'setInspectorTab', value: 'actions' })];
17993
+ }
17960
17994
  // ←/→ on the status surface jump between the staged / unstaged /
17961
17995
  // untracked groups — the horizontal axis is "between groups", the
17962
17996
  // vertical axis (↑/↓ below) is "within the active group's files".
@@ -18683,7 +18717,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18683
18717
  * fall back to "already seen" so we never block startup.
18684
18718
  */
18685
18719
  const MARKER_BASENAME = 'onboarding.seen';
18686
- function resolveCacheDir$2() {
18720
+ function resolveCacheDir$3() {
18687
18721
  const xdg = process.env.XDG_CACHE_HOME;
18688
18722
  if (xdg && xdg.trim().length > 0) {
18689
18723
  return path__namespace$1.join(xdg, 'coco');
@@ -18691,7 +18725,7 @@ function resolveCacheDir$2() {
18691
18725
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18692
18726
  }
18693
18727
  function getOnboardingMarkerPath() {
18694
- return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
18728
+ return path__namespace$1.join(resolveCacheDir$3(), MARKER_BASENAME);
18695
18729
  }
18696
18730
  function hasSeenOnboarding() {
18697
18731
  try {
@@ -18722,14 +18756,14 @@ function markOnboardingSeen() {
18722
18756
  * settings: best-effort, XDG-friendly, no PII in the cache filename.
18723
18757
  */
18724
18758
  const VALID_MODES = ['unified', 'split'];
18725
- function resolveCacheDir$1() {
18759
+ function resolveCacheDir$2() {
18726
18760
  const xdg = process.env.XDG_CACHE_HOME;
18727
18761
  if (xdg && xdg.trim().length > 0) {
18728
18762
  return path__namespace$1.join(xdg, 'coco');
18729
18763
  }
18730
18764
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18731
18765
  }
18732
- function repoKey$1(repoPath) {
18766
+ function repoKey$2(repoPath) {
18733
18767
  // sha1 is used here as a non-security cache-key derivation — we just
18734
18768
  // need a deterministic short identifier for the marker filename. No
18735
18769
  // PII or auth context is hashed.
@@ -18737,7 +18771,7 @@ function repoKey$1(repoPath) {
18737
18771
  return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18738
18772
  }
18739
18773
  function getDiffViewModeMarkerPath(repoPath) {
18740
- return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18774
+ return path__namespace$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
18741
18775
  }
18742
18776
  function getSavedDiffViewMode(repoPath) {
18743
18777
  try {
@@ -18779,14 +18813,14 @@ const VALID_TABS = [
18779
18813
  'stashes',
18780
18814
  'worktrees',
18781
18815
  ];
18782
- function resolveCacheDir() {
18816
+ function resolveCacheDir$1() {
18783
18817
  const xdg = process.env.XDG_CACHE_HOME;
18784
18818
  if (xdg && xdg.trim().length > 0) {
18785
18819
  return path__namespace$1.join(xdg, 'coco');
18786
18820
  }
18787
18821
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18788
18822
  }
18789
- function repoKey(repoPath) {
18823
+ function repoKey$1(repoPath) {
18790
18824
  // sha1 is used here as a non-security cache-key derivation — we just
18791
18825
  // need a deterministic short identifier for the marker filename so
18792
18826
  // re-creating a repo at the same path keeps the same preference.
@@ -18796,7 +18830,7 @@ function repoKey(repoPath) {
18796
18830
  return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18797
18831
  }
18798
18832
  function getSidebarTabMarkerPath(repoPath) {
18799
- return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
18833
+ return path__namespace$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
18800
18834
  }
18801
18835
  function getSavedSidebarTab(repoPath) {
18802
18836
  try {
@@ -19223,9 +19257,16 @@ function getLogInkLayout(input) {
19223
19257
  // graph dominant; focus expansion gives the inspector room for long
19224
19258
  // commit bodies / file lists / action labels. Mirrors the sidebar
19225
19259
  // pattern (sidebarFocused above): instant transition per render.
19226
- const detailWidth = input.inspectorFocused
19227
- ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19228
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19260
+ //
19261
+ // Help overlay overrides both — it borrows ~50% of the terminal so
19262
+ // hotkey descriptions render in full instead of truncating to
19263
+ // "Move focus...". Capped at 100 cells so a wide terminal doesn't
19264
+ // waste an absurd amount of horizontal space on the cheat sheet.
19265
+ const detailWidth = input.helpOverlayActive
19266
+ ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
19267
+ : input.inspectorFocused
19268
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19269
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19229
19270
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
19230
19271
  // (~36% of width). The transition is instant per render — focus tab to
19231
19272
  // expand, focus away to collapse.
@@ -23717,15 +23758,20 @@ async function loadLogInkContext(git) {
23717
23758
  };
23718
23759
  }
23719
23760
  function loadLogInkContextEntries(git) {
23761
+ // Boot-time per-key fetches. Each load() runs in parallel from
23762
+ // `LogInkApp`'s mount effect. `pullRequest` is intentionally
23763
+ // omitted (#808) — its `gh pr view --json` call duplicates the
23764
+ // slim PR fetch already happening inside `getProviderOverview`,
23765
+ // and the only consumer that needs the *full* enriched response is
23766
+ // the dedicated PR view (`g p`). Lazy-loaded by a separate effect
23767
+ // when the user actually navigates there. Header / yank / workflow
23768
+ // paths read the slim version off `provider.currentPullRequest` so
23769
+ // the chrome stays populated immediately on boot.
23720
23770
  return [
23721
23771
  {
23722
23772
  key: 'branches',
23723
23773
  load: () => safe(getBranchOverview(git)),
23724
23774
  },
23725
- {
23726
- key: 'pullRequest',
23727
- load: () => safe(getPullRequestOverview(git)),
23728
- },
23729
23775
  {
23730
23776
  key: 'tags',
23731
23777
  load: () => safe(getTagOverview(git)),
@@ -23938,8 +23984,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23938
23984
  const input = streams.input || process.stdin;
23939
23985
  const output = streams.output || process.stdout;
23940
23986
  const error = streams.error || process.stderr;
23987
+ // Non-TTY fallback (CI logs, piped output) needs the rows up-front
23988
+ // because the renderer just dumps a static snapshot. Run the
23989
+ // deferred loader synchronously here when present so callers get
23990
+ // the same shape regardless of the entry path.
23941
23991
  if (!canStartLogInkTui(input, output)) {
23942
- await startInteractiveLog(git, rows, {
23992
+ const fallbackRows = options.loadRows && rows.length === 0
23993
+ ? await options.loadRows()
23994
+ : rows;
23995
+ await startInteractiveLog(git, fallbackRows, {
23943
23996
  appLabel: options.appLabel,
23944
23997
  input,
23945
23998
  output,
@@ -23958,6 +24011,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23958
24011
  ink,
23959
24012
  initialView: options.initialView || 'history',
23960
24013
  logArgv: options.logArgv,
24014
+ loadRows: options.loadRows,
23961
24015
  React,
23962
24016
  rows,
23963
24017
  theme: createLogInkTheme(options.theme),
@@ -24054,7 +24108,7 @@ function enrichFilterActionWithRectification(action, state, context) {
24054
24108
  }
24055
24109
  }
24056
24110
  function LogInkApp(deps) {
24057
- const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
24111
+ const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
24058
24112
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
24059
24113
  const h = React.createElement;
24060
24114
  const { exit } = useApp();
@@ -24075,9 +24129,26 @@ function LogInkApp(deps) {
24075
24129
  // user's cache dir so the tip never reappears once dismissed. Lazy
24076
24130
  // initializer so the fs check only runs on mount, not every render.
24077
24131
  const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
24078
- const [state, setState] = React.useState(() => createLogInkState(rows, { activeView: initialView }));
24132
+ const [state, setState] = React.useState(() => createLogInkState(rows, {
24133
+ activeView: initialView,
24134
+ // Boot loader is in flight whenever the caller passed
24135
+ // `loadRows`, regardless of whether `rows` was empty or
24136
+ // pre-populated from the disk cache (#808). The history
24137
+ // surface only shows the "Loading commits…" placeholder when
24138
+ // there are zero visible commits, so cached data renders
24139
+ // immediately while the chrome still flags the refresh.
24140
+ bootLoading: Boolean(loadRows),
24141
+ }));
24079
24142
  const [context, setContext] = React.useState({});
24080
- const [contextStatus, setContextStatus] = React.useState(() => createLogInkContextStatus('loading'));
24143
+ const [contextStatus, setContextStatus] = React.useState(() => {
24144
+ // Boot starts every fetched key in 'loading' so the surfaces show
24145
+ // their loading hints immediately. `pullRequest` is the exception
24146
+ // (#808) — it isn't part of the boot fetch entries; it lazy-loads
24147
+ // when the user enters the PR view. Marking it 'idle' avoids a
24148
+ // permanent "loading" flag in the chrome and lets the dedicated
24149
+ // PR view's own load effect drive its loading state.
24150
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
24151
+ });
24081
24152
  const [detail, setDetail] = React.useState(undefined);
24082
24153
  const [detailLoading, setDetailLoading] = React.useState(false);
24083
24154
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -24148,9 +24219,78 @@ function LogInkApp(deps) {
24148
24219
  const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24149
24220
  const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24150
24221
  const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
24222
+ // Stash patch per-file segmentation (#808). Hoisted out of the
24223
+ // useInput callback (was running on every keystroke), the yank
24224
+ // handler (was running per `y` press), and renderDiffSurface (was
24225
+ // running per paint) into a single LogInkApp-scoped memo. When the
24226
+ // active stash diff has hundreds of files, the prior fan-out was
24227
+ // re-walking the entire patch text 2-3x per keystroke for no
24228
+ // observable reason — the parsed list is purely a function of the
24229
+ // line array, which only changes when the user opens a different
24230
+ // stash.
24231
+ const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
24232
+ // Filtered promoted-view lists (#808). These were recomputed inline
24233
+ // inside useInput on every keystroke — for a repo with hundreds of
24234
+ // branches / tags and an active filter, that's hundreds of regex
24235
+ // matches per arrow-key press. Memoizing on (raw list, filter)
24236
+ // collapses the work to one pass per filter / data change.
24237
+ const filteredBranchList = React.useMemo(() => {
24238
+ const all = context.branches?.localBranches || [];
24239
+ if (!state.filter)
24240
+ return all;
24241
+ return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
24242
+ }, [context.branches?.localBranches, state.filter]);
24243
+ const filteredTagList = React.useMemo(() => {
24244
+ const all = context.tags?.tags || [];
24245
+ if (!state.filter)
24246
+ return all;
24247
+ return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
24248
+ }, [context.tags?.tags, state.filter]);
24249
+ const filteredStashList = React.useMemo(() => {
24250
+ const all = context.stashes?.stashes || [];
24251
+ if (!state.filter)
24252
+ return all;
24253
+ return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
24254
+ }, [context.stashes?.stashes, state.filter]);
24255
+ const filteredWorktreeList = React.useMemo(() => {
24256
+ const all = context.worktreeList?.worktrees || [];
24257
+ if (!state.filter)
24258
+ return all;
24259
+ return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
24260
+ }, [context.worktreeList?.worktrees, state.filter]);
24151
24261
  const dispatch = React.useCallback((action) => {
24152
24262
  setState((current) => applyLogInkAction(current, action));
24153
24263
  }, []);
24264
+ // Deferred commit-log loader (#808). Runs once on mount when the
24265
+ // caller opted into the lazy boot path. The Ink tree is already on
24266
+ // screen at this point — without this the user stares at a black
24267
+ // terminal during the synchronous git log pre-mount fetch. The
24268
+ // mounted-ref guard prevents a late-resolving promise from
24269
+ // dispatching after the user `q` quits before rows arrive.
24270
+ React.useEffect(() => {
24271
+ if (!loadRows)
24272
+ return;
24273
+ let cancelled = false;
24274
+ void loadRows()
24275
+ .then((nextRows) => {
24276
+ if (cancelled || !mountedRef.current)
24277
+ return;
24278
+ dispatch({ type: 'replaceRows', rows: nextRows });
24279
+ })
24280
+ .catch((error) => {
24281
+ if (cancelled || !mountedRef.current)
24282
+ return;
24283
+ const message = error instanceof Error ? error.message : String(error);
24284
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
24285
+ dispatch({ type: 'setBootLoading', value: false });
24286
+ });
24287
+ return () => {
24288
+ cancelled = true;
24289
+ };
24290
+ // Intentionally one-shot — re-running the boot load on hot
24291
+ // dispatch / loader changes would refetch the entire log on every
24292
+ // re-render. The loader fires once per app mount and that's it.
24293
+ }, []);
24154
24294
  // Auto-dismiss status messages after a short window so transient
24155
24295
  // confirmations ("Pulled current branch", "Edited foo.ts") don't
24156
24296
  // linger forever. Each new message resets the timer; clearing the
@@ -24372,6 +24512,34 @@ function LogInkApp(deps) {
24372
24512
  active = false;
24373
24513
  };
24374
24514
  }, [git]);
24515
+ // Lazy-load the full pullRequest overview (#808). Only fires when
24516
+ // the user actually navigates to the PR view, and only when we
24517
+ // don't already have data (so a workflow-triggered refresh that
24518
+ // hydrated `pullRequest` doesn't re-fetch on view entry). The
24519
+ // dedicated PR view shows its own loading state while this is in
24520
+ // flight; everywhere else (header glyph, yank, workflow runner)
24521
+ // already falls through to the slim `provider.currentPullRequest`
24522
+ // so the chrome stays populated immediately on boot.
24523
+ React.useEffect(() => {
24524
+ if (state.activeView !== 'pull-request')
24525
+ return;
24526
+ if (context.pullRequest)
24527
+ return;
24528
+ let active = true;
24529
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
24530
+ void safe(getPullRequestOverview(git)).then((value) => {
24531
+ if (!active)
24532
+ return;
24533
+ setContext((current) => ({
24534
+ ...current,
24535
+ pullRequest: value,
24536
+ }));
24537
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
24538
+ });
24539
+ return () => {
24540
+ active = false;
24541
+ };
24542
+ }, [git, state.activeView, context.pullRequest]);
24375
24543
  React.useEffect(() => {
24376
24544
  let active = true;
24377
24545
  async function loadDetail() {
@@ -25123,8 +25291,9 @@ function LogInkApp(deps) {
25123
25291
  else if (state.diffSource === 'stash' && stashDiffLines) {
25124
25292
  // Walk back to the most recent file header at or before the
25125
25293
  // current preview offset — same logic the input-context block
25126
- // uses to expose stashDiffSelectedPath.
25127
- const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25294
+ // uses to expose stashDiffSelectedPath. Reads the memoized
25295
+ // parse so the yank handler doesn't re-walk the entire patch.
25296
+ const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
25128
25297
  if (current) {
25129
25298
  value = current.path;
25130
25299
  label = `path ${current.path}`;
@@ -25167,6 +25336,7 @@ function LogInkApp(deps) {
25167
25336
  selected,
25168
25337
  selectedDetailFile,
25169
25338
  stashDiffLines,
25339
+ stashDiffParsedFiles,
25170
25340
  state.activeView,
25171
25341
  state.branchSort,
25172
25342
  state.diffPreviewOffset,
@@ -25381,43 +25551,25 @@ function LogInkApp(deps) {
25381
25551
  // P4.5: navigation in branches/tags/stash uses the FILTERED list
25382
25552
  // length when a filter is active so j/k stay live instead of getting
25383
25553
  // stuck against a full-list count that no longer matches what's on
25384
- // screen.
25385
- const branchVisibleCount = state.filter
25386
- ? (context.branches?.localBranches || [])
25387
- .filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
25388
- .length
25389
- : context.branches?.localBranches.length;
25390
- const tagVisibleCount = state.filter
25391
- ? (context.tags?.tags || [])
25392
- .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
25393
- .length
25394
- : context.tags?.tags.length;
25395
- const visibleStashes = state.filter
25396
- ? (context.stashes?.stashes || [])
25397
- .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
25398
- : (context.stashes?.stashes || []);
25399
- const stashVisibleCount = visibleStashes.length;
25400
- const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
25401
- // The worktrees promoted view is filterable; mirror the branches /
25402
- // tags / stash pattern and feed the filtered count into the input
25403
- // dispatcher so ↑/↓ stay synchronized with the visible rows.
25404
- const worktreeVisibleCount = state.filter
25405
- ? (context.worktreeList?.worktrees || [])
25406
- .filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25407
- .length
25408
- : context.worktreeList?.worktrees.length;
25554
+ // screen. The filtered lists are memoized at LogInkApp scope (#808
25555
+ // perf pass) — reading them here is O(1) instead of O(branches +
25556
+ // tags + stashes + worktrees) per keystroke.
25557
+ const branchVisibleCount = filteredBranchList.length;
25558
+ const tagVisibleCount = filteredTagList.length;
25559
+ const stashVisibleCount = filteredStashList.length;
25560
+ const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
25561
+ const worktreeVisibleCount = filteredWorktreeList.length;
25409
25562
  // When the diff view is showing a stash patch, swap the previewLineCount
25410
25563
  // to the stash diff length so the existing pageDetailPreview path
25411
25564
  // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
25412
25565
  const diffPreviewLineCount = state.diffSource === 'stash'
25413
25566
  ? stashDiffLines?.length
25414
25567
  : filePreview?.hunks.length;
25415
- // Parse the active stash diff into per-file sections so `]`/`[` can
25416
- // jump between files and `c` knows which path the cursor is on for
25417
- // a file-level cherry-pick.
25418
- const stashDiffFiles = state.diffSource === 'stash' && stashDiffLines
25419
- ? parseStashDiffFiles(stashDiffLines)
25420
- : [];
25568
+ // Per-file segmentation for stash diffs reads the LogInkApp-scoped
25569
+ // memo so navigation keys + the input-context derivation share a
25570
+ // single parse pass per stash patch instead of re-walking the
25571
+ // entire patch text on every keystroke.
25572
+ const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
25421
25573
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
25422
25574
  const stashDiffSelectedPath = state.diffSource === 'stash'
25423
25575
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
@@ -25514,6 +25666,7 @@ function LogInkApp(deps) {
25514
25666
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
25515
25667
  sidebarFocused: state.focus === 'sidebar',
25516
25668
  inspectorFocused: state.focus === 'detail',
25669
+ helpOverlayActive: state.showHelp,
25517
25670
  });
25518
25671
  if (layout.tooSmall) {
25519
25672
  return h(Box, {
@@ -25543,7 +25696,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
25543
25696
  ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
25544
25697
  : 'no PR';
25545
25698
  const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
25546
- const loading = isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25699
+ // Boot loading wins over the per-context loading hint because it
25700
+ // tells the user the headline thing they care about (commits aren't
25701
+ // ready yet) — the context fetches finish independently and surface
25702
+ // their own per-section loading copy in the sidebars.
25703
+ const loading = state.bootLoading
25704
+ ? ' loading commits'
25705
+ : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25547
25706
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
25548
25707
  const view = breadcrumb ? ` ${breadcrumb}` : '';
25549
25708
  // Mode indicator (P2.2) — surfaces the current input mode so users
@@ -25850,22 +26009,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25850
26009
  ...(state.historyFetchArgs
25851
26010
  ? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
25852
26011
  : []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
25853
- ? h(Text, { dimColor: true }, formatLogInkHistoryEmpty({
25854
- filter: state.filter,
25855
- totalCommits: state.commits.length,
25856
- }))
26012
+ ? h(Text, { dimColor: true }, state.bootLoading
26013
+ ? formatLogInkLoading({ resource: 'commits' })
26014
+ : formatLogInkHistoryEmpty({
26015
+ filter: state.filter,
26016
+ totalCommits: state.commits.length,
26017
+ }))
25857
26018
  : visible.items.map((item, index) => {
25858
26019
  if (item.type === 'graph') {
26020
+ // Graph-only rows are git's lane-closure scaffolding (`|/`,
26021
+ // `|\`, etc.) — they're real topology but visually they look
26022
+ // like blank rows that the user might wonder if they
26023
+ // accidentally skipped a commit on (#831). Render dim-on-dim
26024
+ // so they retreat as connectors rather than competing with
26025
+ // commit rows for the eye's attention.
25859
26026
  if (item.laneSegments && !theme.ascii) {
25860
- return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
26027
+ return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
25861
26028
  }
25862
26029
  return h(Text, {
25863
26030
  key: `graph-${index}-${item.graph}`,
25864
26031
  color: theme.noColor ? undefined : theme.colors.muted,
25865
- dimColor: theme.noColor,
25866
- }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
26032
+ dimColor: true,
26033
+ }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
25867
26034
  }
25868
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
26035
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
25869
26036
  }));
25870
26037
  }
25871
26038
  /**
@@ -25878,7 +26045,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25878
26045
  * Final padding is appended as its own span so callers do not need to
25879
26046
  * pre-pad the graph string before computing lane segments.
25880
26047
  */
25881
- function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
26048
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
25882
26049
  const muted = theme.noColor ? undefined : theme.colors.muted;
25883
26050
  const elements = [];
25884
26051
  let totalLen = 0;
@@ -25887,7 +26054,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25887
26054
  elements.push(h(Text, {
25888
26055
  key: `${keyPrefix}-${idx}`,
25889
26056
  color: laneColor ?? muted,
25890
- dimColor: theme.noColor && seg.laneId === undefined,
26057
+ // Ink does not cascade dimColor from a parent Text to children,
26058
+ // so the caller's "this whole row should fade" intent has to
26059
+ // travel here as an explicit flag (#831). Used for graph-only
26060
+ // lane-closure rows, where the lane colors otherwise compete
26061
+ // for attention with the commits they connect.
26062
+ dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
25891
26063
  }, seg.text));
25892
26064
  totalLen += seg.text.length;
25893
26065
  });
@@ -25908,11 +26080,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25908
26080
  * Truncation is per-segment so the variable-length message field gets
25909
26081
  * the leftover budget after fixed segments are accounted for.
25910
26082
  */
25911
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
26083
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
25912
26084
  const refs = formatInkRefLabels(commit.refs);
25913
- const totalWidth = 140;
26085
+ // Total cells available to the row content. Earlier revisions used a
26086
+ // hardcoded 140 here, which let row content overflow whenever the
26087
+ // panel was narrower than that — Ink would wrap onto a second visual
26088
+ // line and the next commit's graph indicator landed against the wrap
26089
+ // continuation rather than its own commit (#830). Subtracting 4
26090
+ // accounts for the panel's left + right border + 1-cell padding.
26091
+ const totalWidth = Math.max(20, panelWidth - 4);
25914
26092
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
25915
- const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refs));
26093
+ // Refs trail the message and shrink first when the row is narrow:
26094
+ // the user can always see the full ref list in the inspector, so
26095
+ // the headline subject keeps priority over decoration.
26096
+ const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
26097
+ const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
26098
+ const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
25916
26099
  const message = truncate$1(commit.message, messageRoom);
25917
26100
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
25918
26101
  const accent = theme.noColor ? undefined : theme.colors.accent;
@@ -25927,7 +26110,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
25927
26110
  key: `${commit.hash}-${index}`,
25928
26111
  backgroundColor: selectedBg,
25929
26112
  inverse: selected,
25930
- }, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refs ? h(Text, { color: accent }, refs) : null);
26113
+ }, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
25931
26114
  }
25932
26115
  /**
25933
26116
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -26201,6 +26384,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26201
26384
  : `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
26202
26385
  const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
26203
26386
  const loadingLabel = formatLogInkLoading({ resource: 'branches' });
26387
+ // Per-column width derived from the visible window (#833) so columns
26388
+ // align across rows regardless of name length. Padded to the longest
26389
+ // name in view so short rows fill out instead of leaving a gutter;
26390
+ // capped at 40 cells so one runaway long branch name doesn't blow
26391
+ // out the timestamp column entirely (longer names get truncated and
26392
+ // the timestamp stays where the user expects it).
26393
+ const nameColWidth = visible.length === 0
26394
+ ? 28
26395
+ : Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
26204
26396
  const lines = loading
26205
26397
  ? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
26206
26398
  : localBranches.length === 0
@@ -26214,18 +26406,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26214
26406
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
26215
26407
  // Split the row into spans so the timestamp stays dim even on the
26216
26408
  // currently-selected (bold) row. The leading marker + name keep
26217
- // their original column widths; the timestamp is right-padded so
26218
- // the divergence column stays aligned across rows.
26219
- const namePadded = branch.shortName.padEnd(28);
26409
+ // their per-window-derived column widths; the timestamp is
26410
+ // right-padded so the divergence column stays aligned across rows.
26411
+ const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
26220
26412
  const timestampPadded = lastTouched.padEnd(8);
26221
26413
  const lineDim = !isSelected && !branch.current;
26222
26414
  const head = `${cursor} ${marker} ${namePadded} `;
26223
26415
  const trailingDivergence = divergence ? ` ${divergence}` : '';
26224
- // Truncate the assembled line cooperatively so we never overflow
26225
- // the panel; the timestamp is short and the divergence is the
26226
- // most expendable, but the existing 140 cap is ample.
26416
+ // Truncate the assembled line to the actual panel width so a
26417
+ // narrow inspector / sidebar focus doesn't push branch rows
26418
+ // onto a second visual line (#830).
26227
26419
  const fullText = `${head}${timestampPadded}${trailingDivergence}`;
26228
- const truncated = truncate$1(fullText, 140);
26420
+ const truncated = truncate$1(fullText, Math.max(20, width - 4));
26229
26421
  // If truncation chopped into the timestamp/divergence portion,
26230
26422
  // fall back to a single Text to keep the visible width honest.
26231
26423
  if (truncated !== fullText) {
@@ -26269,6 +26461,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26269
26461
  : `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
26270
26462
  const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
26271
26463
  const loadingLabel = formatLogInkLoading({ resource: 'tags' });
26464
+ // Per-window name column width (#833) so short tags don't leave a
26465
+ // wide gutter and long tags don't push the subject off-screen. Cap
26466
+ // matches the branches surface for visual consistency across the
26467
+ // promoted views.
26468
+ const tagNameColWidth = visible.length === 0
26469
+ ? 20
26470
+ : Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
26272
26471
  const lines = loading
26273
26472
  ? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
26274
26473
  : tags.length === 0
@@ -26282,8 +26481,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26282
26481
  // formatHyperlink wraps just the tag name, leaving width math
26283
26482
  // intact.
26284
26483
  const url = buildRefUrl(context.provider?.repository, tag.name);
26285
- const namePadded = tag.name.padEnd(20);
26286
- const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, 140);
26484
+ const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
26485
+ const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
26287
26486
  if (!url || lineText.indexOf(namePadded) < 0) {
26288
26487
  return h(Text, {
26289
26488
  key: `tag-${index}`,
@@ -26366,6 +26565,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26366
26565
  const headerRight = loading
26367
26566
  ? 'loading worktrees'
26368
26567
  : `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
26568
+ // Per-window branch column width (#833). Worktrees often track
26569
+ // branches with names varying widely in length (`main` vs.
26570
+ // `feat/tui-something-long`); fixed-width padding either left a
26571
+ // huge gutter on short rows or pushed the path column off-screen on
26572
+ // long ones. Cap matches the other promoted surfaces.
26573
+ const branchColWidth = visible.length === 0
26574
+ ? 28
26575
+ : Math.min(40, Math.max(8, ...visible.map((entry) => {
26576
+ const label = entry.branch ? entry.branch : entry.head || '<detached>';
26577
+ return label.length;
26578
+ })));
26369
26579
  const lines = loading
26370
26580
  ? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
26371
26581
  : worktrees.length === 0
@@ -26377,11 +26587,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26377
26587
  const marker = entry.current ? '*' : ' ';
26378
26588
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
26379
26589
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
26590
+ const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
26380
26591
  return h(Text, {
26381
26592
  key: `worktree-${index}`,
26382
26593
  bold: isSelected,
26383
26594
  dimColor: !isSelected && !entry.current,
26384
- }, truncate$1(`${cursor} ${marker} ${branchLabel.padEnd(28)} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26595
+ }, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26385
26596
  });
26386
26597
  return h(Box, {
26387
26598
  borderColor: focusBorderColor(theme, focused),
@@ -26803,22 +27014,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26803
27014
  h(Text, { key: 'detail-spacer-3' }, ''),
26804
27015
  h(Text, { key: 'detail-files-title' }, 'Changed files:'),
26805
27016
  ];
27017
+ // Single-cursor invariant: the file list owns the cursor when the
27018
+ // inspector tab is active; the actions list owns it when the actions
27019
+ // tab is active. Pass `focused` only for the matching tab so users
27020
+ // never see two simultaneous selection highlights inside the panel.
27021
+ const fileListFocused = focused && state.inspectorTab === 'inspector';
26806
27022
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
26807
- const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
26808
- // Tabbed mode (#806 follow-up short terminals): render only the
26809
- // active inspector tab with a `[Inspector] Actions` header so the
26810
- // user knows what they're seeing and how to switch (`[/]` while
26811
- // focus is on the inspector). Tall terminals stack both sections
26812
- // as before.
27023
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
27024
+ // Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
27025
+ // tall-stacked mode so the user can always see which tab the cursor
27026
+ // owns and learn the `[/]` toggle. Without this on tall terminals,
27027
+ // the actions list looked like a static cheat-sheet there was no
27028
+ // visible signal that the cursor could move into it.
27029
+ //
27030
+ // Spacing between tab labels comes from the labels' own padding
27031
+ // (the active label is bracketed `[Inspector]` while the inactive
27032
+ // one is space-padded ` Inspector `, so adjacency reads cleanly).
27033
+ // Earlier revisions stuck a raw `' '` between the Text children to
27034
+ // pad them visually — that crashes Ink at first paint with
27035
+ // "Text string ' ' must be rendered inside <Text> component"
27036
+ // because Box only accepts component children, never bare strings.
27037
+ const activeTab = state.inspectorTab;
27038
+ const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
27039
+ bold: activeTab === 'inspector',
27040
+ dimColor: activeTab !== 'inspector',
27041
+ }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
27042
+ bold: activeTab === 'actions',
27043
+ dimColor: activeTab !== 'actions',
27044
+ }, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
27045
+ ? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
27046
+ : []));
27047
+ // Tabbed mode (short terminals): render only the active tab's
27048
+ // content under the tab header.
26813
27049
  if (tabbed) {
26814
- const activeTab = state.inspectorTab;
26815
- const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26816
- bold: activeTab === 'inspector',
26817
- dimColor: activeTab !== 'inspector',
26818
- }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
26819
- bold: activeTab === 'actions',
26820
- dimColor: activeTab !== 'actions',
26821
- }, activeTab === 'actions' ? '[Actions]' : ' Actions '));
26822
27050
  return h(Box, {
26823
27051
  borderColor: focusBorderColor(theme, focused),
26824
27052
  borderStyle: theme.borderStyle,
@@ -26832,13 +27060,16 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26832
27060
  cursorActive: focused,
26833
27061
  })));
26834
27062
  }
27063
+ // Tall mode: stack both sections so the user can read everything at
27064
+ // once, but show the tab header so the active section (and the
27065
+ // `[/]` switch affordance) is visible.
26835
27066
  return h(Box, {
26836
27067
  borderColor: focusBorderColor(theme, focused),
26837
27068
  borderStyle: theme.borderStyle,
26838
27069
  flexDirection: 'column',
26839
27070
  width,
26840
27071
  paddingX: 1,
26841
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
27072
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26842
27073
  cursorIndex: state.inspectorActionIndex,
26843
27074
  cursorActive: focused && state.inspectorTab === 'actions',
26844
27075
  }));
@@ -27441,6 +27672,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
27441
27672
  }, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
27442
27673
  }
27443
27674
 
27675
+ /**
27676
+ * Per-repo disk cache of the last successful commit-log fetch (#808).
27677
+ * Lets the TUI render an immediate stale-but-useful history view on
27678
+ * subsequent boots while the fresh `git log` runs in the background;
27679
+ * once the fresh data lands the runtime swaps it in transparently.
27680
+ *
27681
+ * Strict best-effort: read failures fall back to "no cache" (boot
27682
+ * shows the loading placeholder), and write failures are swallowed
27683
+ * silently (next boot just doesn't have the cache yet). The cache is
27684
+ * never load-bearing.
27685
+ *
27686
+ * Repos are keyed by a short hash of their absolute path. No PII in
27687
+ * the cache filename, and re-creating a repo at the same path keeps
27688
+ * the same cache.
27689
+ */
27690
+ const CACHE_SCHEMA_VERSION = 1;
27691
+ const CACHE_DIR_NAME = 'overview';
27692
+ /**
27693
+ * Hard cap on rows we'll write per cache entry. The interactive
27694
+ * default limit is 300; this caps growth in case a user opts into a
27695
+ * much larger window. Keeps the cache file under ~200kb on a typical
27696
+ * repo.
27697
+ */
27698
+ const CACHE_ROW_HARD_CAP = 500;
27699
+ function resolveCacheDir() {
27700
+ const xdg = process.env.XDG_CACHE_HOME;
27701
+ if (xdg && xdg.trim().length > 0) {
27702
+ return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME);
27703
+ }
27704
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
27705
+ }
27706
+ function repoKey(repoPath) {
27707
+ // sha1 here is a non-security cache-key derivation — we just need a
27708
+ // deterministic short identifier for the cache filename so two repos
27709
+ // at different paths never collide. No PII or auth context is hashed
27710
+ // and no collision-resistance against an adversary is required.
27711
+ // DevSkim DS126858 doesn't apply.
27712
+ // DevSkim: ignore DS126858
27713
+ return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
27714
+ }
27715
+ function getOverviewCachePath(repoPath) {
27716
+ return path__namespace$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
27717
+ }
27718
+ function readCachedCommits(repoPath) {
27719
+ try {
27720
+ const raw = fs__namespace$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
27721
+ const parsed = JSON.parse(raw);
27722
+ if (parsed.version !== CACHE_SCHEMA_VERSION) {
27723
+ // Schema mismatch — quietly drop the stale entry on next write.
27724
+ // Treating it as "no cache" keeps boot behavior predictable
27725
+ // across upgrades.
27726
+ return undefined;
27727
+ }
27728
+ if (!Array.isArray(parsed.rows)) {
27729
+ return undefined;
27730
+ }
27731
+ return parsed.rows;
27732
+ }
27733
+ catch {
27734
+ return undefined;
27735
+ }
27736
+ }
27737
+ function writeCachedCommits(repoPath, rows) {
27738
+ const file = getOverviewCachePath(repoPath);
27739
+ const envelope = {
27740
+ version: CACHE_SCHEMA_VERSION,
27741
+ savedAt: new Date().toISOString(),
27742
+ rows: rows.slice(0, CACHE_ROW_HARD_CAP),
27743
+ };
27744
+ try {
27745
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
27746
+ fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
27747
+ }
27748
+ catch {
27749
+ // Best-effort persistence; swallow.
27750
+ }
27751
+ }
27752
+
27444
27753
  function createLogArgvFromUiArgv(argv) {
27445
27754
  return {
27446
27755
  $0: argv.$0,
@@ -27465,14 +27774,43 @@ function createUiTheme(config, argv) {
27465
27774
  preset: argv.theme,
27466
27775
  };
27467
27776
  }
27777
+ /**
27778
+ * Wrap a fresh-rows loader with the disk-cache write step. Lets the
27779
+ * runtime stay caching-agnostic — it just receives the rows and
27780
+ * doesn't know whether they came from cache or git, while the caller
27781
+ * (which knows the repo path) handles persistence.
27782
+ */
27783
+ function withCacheWrite(repoPath, loader) {
27784
+ return async () => {
27785
+ const rows = await loader();
27786
+ writeCachedCommits(repoPath, rows);
27787
+ return rows;
27788
+ };
27789
+ }
27468
27790
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
27469
27791
  const config = options.config || loadConfig(logArgv);
27470
27792
  const git = options.git || getRepo();
27471
- const rows = options.rows || (await getLogRows(git, logArgv));
27472
- await startInkInteractiveLog(git, rows, {}, {
27793
+ const repoPath = process.cwd();
27794
+ // Three-stage boot (#808):
27795
+ // 1. Read the disk cache and pass cached rows as the initial set
27796
+ // so the user sees the workstation chrome populated with
27797
+ // commits in the first frame.
27798
+ // 2. Mount Ink immediately with those rows (or [] if no cache).
27799
+ // 3. Run loadRows in the background to refresh — when fresh data
27800
+ // lands the runtime swaps it in transparently and we persist
27801
+ // the new rows back to the cache for next boot.
27802
+ // Caller-provided rows skip the lazy path entirely (caller already
27803
+ // has up-to-date data — no point redoing the fetch).
27804
+ const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
27805
+ const initialRows = options.rows || cachedRows || [];
27806
+ const loadRows = options.rows
27807
+ ? undefined
27808
+ : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
27809
+ await startInkInteractiveLog(git, initialRows, {}, {
27473
27810
  appLabel: 'coco',
27474
27811
  idleTips: config.logTui?.idleTips,
27475
27812
  initialView: 'history',
27813
+ loadRows,
27476
27814
  logArgv,
27477
27815
  theme: config.logTui?.theme,
27478
27816
  });
@@ -27481,11 +27819,15 @@ async function startCocoUi(argv) {
27481
27819
  const config = loadConfig(argv);
27482
27820
  const git = getRepo();
27483
27821
  const logArgv = createLogArgvFromUiArgv(argv);
27484
- const rows = await getLogRows(git, logArgv);
27485
- await startInkInteractiveLog(git, rows, {}, {
27822
+ const repoPath = process.cwd();
27823
+ // Same three-stage boot as startCocoUiFromLogArgv — mount with
27824
+ // cached rows for an instant-paint shell, refresh in background.
27825
+ const cachedRows = readCachedCommits(repoPath);
27826
+ await startInkInteractiveLog(git, cachedRows || [], {}, {
27486
27827
  appLabel: 'coco',
27487
27828
  idleTips: config.logTui?.idleTips,
27488
27829
  initialView: argv.view || 'history',
27830
+ loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
27489
27831
  logArgv,
27490
27832
  theme: createUiTheme(config, argv),
27491
27833
  });
@@ -27595,15 +27937,18 @@ const handler$2 = async (argv) => {
27595
27937
  });
27596
27938
  return;
27597
27939
  }
27598
- const rows = await getLogRows(git, argv);
27940
+ // Interactive path defers the commit log fetch into the runtime
27941
+ // (#808) so the TUI mounts immediately with a "Loading commits…"
27942
+ // placeholder. The non-interactive (stdout) path still needs rows
27943
+ // up-front because the formatter just dumps a static snapshot.
27599
27944
  if (argv.interactive && format === 'table') {
27600
27945
  await startCocoUiFromLogArgv(argv, {
27601
27946
  config,
27602
27947
  git,
27603
- rows,
27604
27948
  });
27605
27949
  return;
27606
27950
  }
27951
+ const rows = await getLogRows(git, argv);
27607
27952
  const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
27608
27953
  await handleResult({
27609
27954
  result,