git-coco 0.40.1 → 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.
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
53
53
  /**
54
54
  * Current build version from package.json
55
55
  */
56
- const BUILD_VERSION = "0.40.1";
56
+ const BUILD_VERSION = "0.41.0";
57
57
 
58
58
  const isInteractive = (config) => {
59
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -16313,6 +16313,11 @@ function replaceRows(state, rows) {
16313
16313
  selectedFileIndex: 0,
16314
16314
  pendingCommitFocused: false,
16315
16315
  pendingKey: undefined,
16316
+ // Rows just landed — clear the boot-loading flag so the history
16317
+ // surface drops the "Loading commits…" placeholder. Safe to clear
16318
+ // unconditionally because `replaceRows` only fires after a real
16319
+ // git log returns.
16320
+ bootLoading: false,
16316
16321
  };
16317
16322
  }
16318
16323
  function appendRows(state, rows) {
@@ -16403,6 +16408,7 @@ function createLogInkState(rows, options = {}) {
16403
16408
  diffViewMode: 'unified',
16404
16409
  inspectorTab: 'inspector',
16405
16410
  inspectorActionIndex: 0,
16411
+ bootLoading: options.bootLoading ?? false,
16406
16412
  };
16407
16413
  }
16408
16414
  function getSelectedInkCommit(state) {
@@ -16604,6 +16610,12 @@ function applyLogInkAction(state, action) {
16604
16610
  inspectorActionIndex: 0,
16605
16611
  pendingKey: undefined,
16606
16612
  };
16613
+ case 'setBootLoading':
16614
+ return {
16615
+ ...state,
16616
+ bootLoading: action.value,
16617
+ pendingKey: undefined,
16618
+ };
16607
16619
  case 'moveTag':
16608
16620
  return {
16609
16621
  ...state,
@@ -18680,7 +18692,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18680
18692
  * fall back to "already seen" so we never block startup.
18681
18693
  */
18682
18694
  const MARKER_BASENAME = 'onboarding.seen';
18683
- function resolveCacheDir$2() {
18695
+ function resolveCacheDir$3() {
18684
18696
  const xdg = process.env.XDG_CACHE_HOME;
18685
18697
  if (xdg && xdg.trim().length > 0) {
18686
18698
  return path$1.join(xdg, 'coco');
@@ -18688,7 +18700,7 @@ function resolveCacheDir$2() {
18688
18700
  return path$1.join(os$1.homedir(), '.cache', 'coco');
18689
18701
  }
18690
18702
  function getOnboardingMarkerPath() {
18691
- return path$1.join(resolveCacheDir$2(), MARKER_BASENAME);
18703
+ return path$1.join(resolveCacheDir$3(), MARKER_BASENAME);
18692
18704
  }
18693
18705
  function hasSeenOnboarding() {
18694
18706
  try {
@@ -18719,14 +18731,14 @@ function markOnboardingSeen() {
18719
18731
  * settings: best-effort, XDG-friendly, no PII in the cache filename.
18720
18732
  */
18721
18733
  const VALID_MODES = ['unified', 'split'];
18722
- function resolveCacheDir$1() {
18734
+ function resolveCacheDir$2() {
18723
18735
  const xdg = process.env.XDG_CACHE_HOME;
18724
18736
  if (xdg && xdg.trim().length > 0) {
18725
18737
  return path$1.join(xdg, 'coco');
18726
18738
  }
18727
18739
  return path$1.join(os$1.homedir(), '.cache', 'coco');
18728
18740
  }
18729
- function repoKey$1(repoPath) {
18741
+ function repoKey$2(repoPath) {
18730
18742
  // sha1 is used here as a non-security cache-key derivation — we just
18731
18743
  // need a deterministic short identifier for the marker filename. No
18732
18744
  // PII or auth context is hashed.
@@ -18734,7 +18746,7 @@ function repoKey$1(repoPath) {
18734
18746
  return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18735
18747
  }
18736
18748
  function getDiffViewModeMarkerPath(repoPath) {
18737
- return path$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18749
+ return path$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
18738
18750
  }
18739
18751
  function getSavedDiffViewMode(repoPath) {
18740
18752
  try {
@@ -18776,14 +18788,14 @@ const VALID_TABS = [
18776
18788
  'stashes',
18777
18789
  'worktrees',
18778
18790
  ];
18779
- function resolveCacheDir() {
18791
+ function resolveCacheDir$1() {
18780
18792
  const xdg = process.env.XDG_CACHE_HOME;
18781
18793
  if (xdg && xdg.trim().length > 0) {
18782
18794
  return path$1.join(xdg, 'coco');
18783
18795
  }
18784
18796
  return path$1.join(os$1.homedir(), '.cache', 'coco');
18785
18797
  }
18786
- function repoKey(repoPath) {
18798
+ function repoKey$1(repoPath) {
18787
18799
  // sha1 is used here as a non-security cache-key derivation — we just
18788
18800
  // need a deterministic short identifier for the marker filename so
18789
18801
  // re-creating a repo at the same path keeps the same preference.
@@ -18793,7 +18805,7 @@ function repoKey(repoPath) {
18793
18805
  return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18794
18806
  }
18795
18807
  function getSidebarTabMarkerPath(repoPath) {
18796
- return path$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
18808
+ return path$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
18797
18809
  }
18798
18810
  function getSavedSidebarTab(repoPath) {
18799
18811
  try {
@@ -19220,9 +19232,16 @@ function getLogInkLayout(input) {
19220
19232
  // graph dominant; focus expansion gives the inspector room for long
19221
19233
  // commit bodies / file lists / action labels. Mirrors the sidebar
19222
19234
  // pattern (sidebarFocused above): instant transition per render.
19223
- const detailWidth = input.inspectorFocused
19224
- ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19225
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19235
+ //
19236
+ // Help overlay overrides both — it borrows ~50% of the terminal so
19237
+ // hotkey descriptions render in full instead of truncating to
19238
+ // "Move focus...". Capped at 100 cells so a wide terminal doesn't
19239
+ // waste an absurd amount of horizontal space on the cheat sheet.
19240
+ const detailWidth = input.helpOverlayActive
19241
+ ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
19242
+ : input.inspectorFocused
19243
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19244
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19226
19245
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
19227
19246
  // (~36% of width). The transition is instant per render — focus tab to
19228
19247
  // expand, focus away to collapse.
@@ -23714,15 +23733,20 @@ async function loadLogInkContext(git) {
23714
23733
  };
23715
23734
  }
23716
23735
  function loadLogInkContextEntries(git) {
23736
+ // Boot-time per-key fetches. Each load() runs in parallel from
23737
+ // `LogInkApp`'s mount effect. `pullRequest` is intentionally
23738
+ // omitted (#808) — its `gh pr view --json` call duplicates the
23739
+ // slim PR fetch already happening inside `getProviderOverview`,
23740
+ // and the only consumer that needs the *full* enriched response is
23741
+ // the dedicated PR view (`g p`). Lazy-loaded by a separate effect
23742
+ // when the user actually navigates there. Header / yank / workflow
23743
+ // paths read the slim version off `provider.currentPullRequest` so
23744
+ // the chrome stays populated immediately on boot.
23717
23745
  return [
23718
23746
  {
23719
23747
  key: 'branches',
23720
23748
  load: () => safe(getBranchOverview(git)),
23721
23749
  },
23722
- {
23723
- key: 'pullRequest',
23724
- load: () => safe(getPullRequestOverview(git)),
23725
- },
23726
23750
  {
23727
23751
  key: 'tags',
23728
23752
  load: () => safe(getTagOverview(git)),
@@ -23935,8 +23959,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23935
23959
  const input = streams.input || process.stdin;
23936
23960
  const output = streams.output || process.stdout;
23937
23961
  const error = streams.error || process.stderr;
23962
+ // Non-TTY fallback (CI logs, piped output) needs the rows up-front
23963
+ // because the renderer just dumps a static snapshot. Run the
23964
+ // deferred loader synchronously here when present so callers get
23965
+ // the same shape regardless of the entry path.
23938
23966
  if (!canStartLogInkTui(input, output)) {
23939
- await startInteractiveLog(git, rows, {
23967
+ const fallbackRows = options.loadRows && rows.length === 0
23968
+ ? await options.loadRows()
23969
+ : rows;
23970
+ await startInteractiveLog(git, fallbackRows, {
23940
23971
  appLabel: options.appLabel,
23941
23972
  input,
23942
23973
  output,
@@ -23955,6 +23986,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23955
23986
  ink,
23956
23987
  initialView: options.initialView || 'history',
23957
23988
  logArgv: options.logArgv,
23989
+ loadRows: options.loadRows,
23958
23990
  React,
23959
23991
  rows,
23960
23992
  theme: createLogInkTheme(options.theme),
@@ -24051,7 +24083,7 @@ function enrichFilterActionWithRectification(action, state, context) {
24051
24083
  }
24052
24084
  }
24053
24085
  function LogInkApp(deps) {
24054
- const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
24086
+ const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
24055
24087
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
24056
24088
  const h = React.createElement;
24057
24089
  const { exit } = useApp();
@@ -24072,9 +24104,26 @@ function LogInkApp(deps) {
24072
24104
  // user's cache dir so the tip never reappears once dismissed. Lazy
24073
24105
  // initializer so the fs check only runs on mount, not every render.
24074
24106
  const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
24075
- const [state, setState] = React.useState(() => createLogInkState(rows, { activeView: initialView }));
24107
+ const [state, setState] = React.useState(() => createLogInkState(rows, {
24108
+ activeView: initialView,
24109
+ // Boot loader is in flight whenever the caller passed
24110
+ // `loadRows`, regardless of whether `rows` was empty or
24111
+ // pre-populated from the disk cache (#808). The history
24112
+ // surface only shows the "Loading commits…" placeholder when
24113
+ // there are zero visible commits, so cached data renders
24114
+ // immediately while the chrome still flags the refresh.
24115
+ bootLoading: Boolean(loadRows),
24116
+ }));
24076
24117
  const [context, setContext] = React.useState({});
24077
- const [contextStatus, setContextStatus] = React.useState(() => createLogInkContextStatus('loading'));
24118
+ const [contextStatus, setContextStatus] = React.useState(() => {
24119
+ // Boot starts every fetched key in 'loading' so the surfaces show
24120
+ // their loading hints immediately. `pullRequest` is the exception
24121
+ // (#808) — it isn't part of the boot fetch entries; it lazy-loads
24122
+ // when the user enters the PR view. Marking it 'idle' avoids a
24123
+ // permanent "loading" flag in the chrome and lets the dedicated
24124
+ // PR view's own load effect drive its loading state.
24125
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
24126
+ });
24078
24127
  const [detail, setDetail] = React.useState(undefined);
24079
24128
  const [detailLoading, setDetailLoading] = React.useState(false);
24080
24129
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -24145,9 +24194,78 @@ function LogInkApp(deps) {
24145
24194
  const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24146
24195
  const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24147
24196
  const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
24197
+ // Stash patch per-file segmentation (#808). Hoisted out of the
24198
+ // useInput callback (was running on every keystroke), the yank
24199
+ // handler (was running per `y` press), and renderDiffSurface (was
24200
+ // running per paint) into a single LogInkApp-scoped memo. When the
24201
+ // active stash diff has hundreds of files, the prior fan-out was
24202
+ // re-walking the entire patch text 2-3x per keystroke for no
24203
+ // observable reason — the parsed list is purely a function of the
24204
+ // line array, which only changes when the user opens a different
24205
+ // stash.
24206
+ const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
24207
+ // Filtered promoted-view lists (#808). These were recomputed inline
24208
+ // inside useInput on every keystroke — for a repo with hundreds of
24209
+ // branches / tags and an active filter, that's hundreds of regex
24210
+ // matches per arrow-key press. Memoizing on (raw list, filter)
24211
+ // collapses the work to one pass per filter / data change.
24212
+ const filteredBranchList = React.useMemo(() => {
24213
+ const all = context.branches?.localBranches || [];
24214
+ if (!state.filter)
24215
+ return all;
24216
+ return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
24217
+ }, [context.branches?.localBranches, state.filter]);
24218
+ const filteredTagList = React.useMemo(() => {
24219
+ const all = context.tags?.tags || [];
24220
+ if (!state.filter)
24221
+ return all;
24222
+ return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
24223
+ }, [context.tags?.tags, state.filter]);
24224
+ const filteredStashList = React.useMemo(() => {
24225
+ const all = context.stashes?.stashes || [];
24226
+ if (!state.filter)
24227
+ return all;
24228
+ return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
24229
+ }, [context.stashes?.stashes, state.filter]);
24230
+ const filteredWorktreeList = React.useMemo(() => {
24231
+ const all = context.worktreeList?.worktrees || [];
24232
+ if (!state.filter)
24233
+ return all;
24234
+ return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
24235
+ }, [context.worktreeList?.worktrees, state.filter]);
24148
24236
  const dispatch = React.useCallback((action) => {
24149
24237
  setState((current) => applyLogInkAction(current, action));
24150
24238
  }, []);
24239
+ // Deferred commit-log loader (#808). Runs once on mount when the
24240
+ // caller opted into the lazy boot path. The Ink tree is already on
24241
+ // screen at this point — without this the user stares at a black
24242
+ // terminal during the synchronous git log pre-mount fetch. The
24243
+ // mounted-ref guard prevents a late-resolving promise from
24244
+ // dispatching after the user `q` quits before rows arrive.
24245
+ React.useEffect(() => {
24246
+ if (!loadRows)
24247
+ return;
24248
+ let cancelled = false;
24249
+ void loadRows()
24250
+ .then((nextRows) => {
24251
+ if (cancelled || !mountedRef.current)
24252
+ return;
24253
+ dispatch({ type: 'replaceRows', rows: nextRows });
24254
+ })
24255
+ .catch((error) => {
24256
+ if (cancelled || !mountedRef.current)
24257
+ return;
24258
+ const message = error instanceof Error ? error.message : String(error);
24259
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
24260
+ dispatch({ type: 'setBootLoading', value: false });
24261
+ });
24262
+ return () => {
24263
+ cancelled = true;
24264
+ };
24265
+ // Intentionally one-shot — re-running the boot load on hot
24266
+ // dispatch / loader changes would refetch the entire log on every
24267
+ // re-render. The loader fires once per app mount and that's it.
24268
+ }, []);
24151
24269
  // Auto-dismiss status messages after a short window so transient
24152
24270
  // confirmations ("Pulled current branch", "Edited foo.ts") don't
24153
24271
  // linger forever. Each new message resets the timer; clearing the
@@ -24369,6 +24487,34 @@ function LogInkApp(deps) {
24369
24487
  active = false;
24370
24488
  };
24371
24489
  }, [git]);
24490
+ // Lazy-load the full pullRequest overview (#808). Only fires when
24491
+ // the user actually navigates to the PR view, and only when we
24492
+ // don't already have data (so a workflow-triggered refresh that
24493
+ // hydrated `pullRequest` doesn't re-fetch on view entry). The
24494
+ // dedicated PR view shows its own loading state while this is in
24495
+ // flight; everywhere else (header glyph, yank, workflow runner)
24496
+ // already falls through to the slim `provider.currentPullRequest`
24497
+ // so the chrome stays populated immediately on boot.
24498
+ React.useEffect(() => {
24499
+ if (state.activeView !== 'pull-request')
24500
+ return;
24501
+ if (context.pullRequest)
24502
+ return;
24503
+ let active = true;
24504
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
24505
+ void safe(getPullRequestOverview(git)).then((value) => {
24506
+ if (!active)
24507
+ return;
24508
+ setContext((current) => ({
24509
+ ...current,
24510
+ pullRequest: value,
24511
+ }));
24512
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
24513
+ });
24514
+ return () => {
24515
+ active = false;
24516
+ };
24517
+ }, [git, state.activeView, context.pullRequest]);
24372
24518
  React.useEffect(() => {
24373
24519
  let active = true;
24374
24520
  async function loadDetail() {
@@ -25120,8 +25266,9 @@ function LogInkApp(deps) {
25120
25266
  else if (state.diffSource === 'stash' && stashDiffLines) {
25121
25267
  // Walk back to the most recent file header at or before the
25122
25268
  // current preview offset — same logic the input-context block
25123
- // uses to expose stashDiffSelectedPath.
25124
- const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25269
+ // uses to expose stashDiffSelectedPath. Reads the memoized
25270
+ // parse so the yank handler doesn't re-walk the entire patch.
25271
+ const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
25125
25272
  if (current) {
25126
25273
  value = current.path;
25127
25274
  label = `path ${current.path}`;
@@ -25164,6 +25311,7 @@ function LogInkApp(deps) {
25164
25311
  selected,
25165
25312
  selectedDetailFile,
25166
25313
  stashDiffLines,
25314
+ stashDiffParsedFiles,
25167
25315
  state.activeView,
25168
25316
  state.branchSort,
25169
25317
  state.diffPreviewOffset,
@@ -25378,43 +25526,25 @@ function LogInkApp(deps) {
25378
25526
  // P4.5: navigation in branches/tags/stash uses the FILTERED list
25379
25527
  // length when a filter is active so j/k stay live instead of getting
25380
25528
  // stuck against a full-list count that no longer matches what's on
25381
- // screen.
25382
- const branchVisibleCount = state.filter
25383
- ? (context.branches?.localBranches || [])
25384
- .filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
25385
- .length
25386
- : context.branches?.localBranches.length;
25387
- const tagVisibleCount = state.filter
25388
- ? (context.tags?.tags || [])
25389
- .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
25390
- .length
25391
- : context.tags?.tags.length;
25392
- const visibleStashes = state.filter
25393
- ? (context.stashes?.stashes || [])
25394
- .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
25395
- : (context.stashes?.stashes || []);
25396
- const stashVisibleCount = visibleStashes.length;
25397
- const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
25398
- // The worktrees promoted view is filterable; mirror the branches /
25399
- // tags / stash pattern and feed the filtered count into the input
25400
- // dispatcher so ↑/↓ stay synchronized with the visible rows.
25401
- const worktreeVisibleCount = state.filter
25402
- ? (context.worktreeList?.worktrees || [])
25403
- .filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25404
- .length
25405
- : context.worktreeList?.worktrees.length;
25529
+ // screen. The filtered lists are memoized at LogInkApp scope (#808
25530
+ // perf pass) — reading them here is O(1) instead of O(branches +
25531
+ // tags + stashes + worktrees) per keystroke.
25532
+ const branchVisibleCount = filteredBranchList.length;
25533
+ const tagVisibleCount = filteredTagList.length;
25534
+ const stashVisibleCount = filteredStashList.length;
25535
+ const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
25536
+ const worktreeVisibleCount = filteredWorktreeList.length;
25406
25537
  // When the diff view is showing a stash patch, swap the previewLineCount
25407
25538
  // to the stash diff length so the existing pageDetailPreview path
25408
25539
  // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
25409
25540
  const diffPreviewLineCount = state.diffSource === 'stash'
25410
25541
  ? stashDiffLines?.length
25411
25542
  : filePreview?.hunks.length;
25412
- // Parse the active stash diff into per-file sections so `]`/`[` can
25413
- // jump between files and `c` knows which path the cursor is on for
25414
- // a file-level cherry-pick.
25415
- const stashDiffFiles = state.diffSource === 'stash' && stashDiffLines
25416
- ? parseStashDiffFiles(stashDiffLines)
25417
- : [];
25543
+ // Per-file segmentation for stash diffs reads the LogInkApp-scoped
25544
+ // memo so navigation keys + the input-context derivation share a
25545
+ // single parse pass per stash patch instead of re-walking the
25546
+ // entire patch text on every keystroke.
25547
+ const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
25418
25548
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
25419
25549
  const stashDiffSelectedPath = state.diffSource === 'stash'
25420
25550
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
@@ -25511,6 +25641,7 @@ function LogInkApp(deps) {
25511
25641
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
25512
25642
  sidebarFocused: state.focus === 'sidebar',
25513
25643
  inspectorFocused: state.focus === 'detail',
25644
+ helpOverlayActive: state.showHelp,
25514
25645
  });
25515
25646
  if (layout.tooSmall) {
25516
25647
  return h(Box, {
@@ -25540,7 +25671,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
25540
25671
  ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
25541
25672
  : 'no PR';
25542
25673
  const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
25543
- const loading = isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25674
+ // Boot loading wins over the per-context loading hint because it
25675
+ // tells the user the headline thing they care about (commits aren't
25676
+ // ready yet) — the context fetches finish independently and surface
25677
+ // their own per-section loading copy in the sidebars.
25678
+ const loading = state.bootLoading
25679
+ ? ' loading commits'
25680
+ : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25544
25681
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
25545
25682
  const view = breadcrumb ? ` ${breadcrumb}` : '';
25546
25683
  // Mode indicator (P2.2) — surfaces the current input mode so users
@@ -25847,22 +25984,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25847
25984
  ...(state.historyFetchArgs
25848
25985
  ? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
25849
25986
  : []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
25850
- ? h(Text, { dimColor: true }, formatLogInkHistoryEmpty({
25851
- filter: state.filter,
25852
- totalCommits: state.commits.length,
25853
- }))
25987
+ ? h(Text, { dimColor: true }, state.bootLoading
25988
+ ? formatLogInkLoading({ resource: 'commits' })
25989
+ : formatLogInkHistoryEmpty({
25990
+ filter: state.filter,
25991
+ totalCommits: state.commits.length,
25992
+ }))
25854
25993
  : visible.items.map((item, index) => {
25855
25994
  if (item.type === 'graph') {
25995
+ // Graph-only rows are git's lane-closure scaffolding (`|/`,
25996
+ // `|\`, etc.) — they're real topology but visually they look
25997
+ // like blank rows that the user might wonder if they
25998
+ // accidentally skipped a commit on (#831). Render dim-on-dim
25999
+ // so they retreat as connectors rather than competing with
26000
+ // commit rows for the eye's attention.
25856
26001
  if (item.laneSegments && !theme.ascii) {
25857
- return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
26002
+ return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
25858
26003
  }
25859
26004
  return h(Text, {
25860
26005
  key: `graph-${index}-${item.graph}`,
25861
26006
  color: theme.noColor ? undefined : theme.colors.muted,
25862
- dimColor: theme.noColor,
25863
- }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
26007
+ dimColor: true,
26008
+ }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
25864
26009
  }
25865
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
26010
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
25866
26011
  }));
25867
26012
  }
25868
26013
  /**
@@ -25875,7 +26020,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25875
26020
  * Final padding is appended as its own span so callers do not need to
25876
26021
  * pre-pad the graph string before computing lane segments.
25877
26022
  */
25878
- function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
26023
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
25879
26024
  const muted = theme.noColor ? undefined : theme.colors.muted;
25880
26025
  const elements = [];
25881
26026
  let totalLen = 0;
@@ -25884,7 +26029,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25884
26029
  elements.push(h(Text, {
25885
26030
  key: `${keyPrefix}-${idx}`,
25886
26031
  color: laneColor ?? muted,
25887
- dimColor: theme.noColor && seg.laneId === undefined,
26032
+ // Ink does not cascade dimColor from a parent Text to children,
26033
+ // so the caller's "this whole row should fade" intent has to
26034
+ // travel here as an explicit flag (#831). Used for graph-only
26035
+ // lane-closure rows, where the lane colors otherwise compete
26036
+ // for attention with the commits they connect.
26037
+ dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
25888
26038
  }, seg.text));
25889
26039
  totalLen += seg.text.length;
25890
26040
  });
@@ -25905,11 +26055,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25905
26055
  * Truncation is per-segment so the variable-length message field gets
25906
26056
  * the leftover budget after fixed segments are accounted for.
25907
26057
  */
25908
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
26058
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
25909
26059
  const refs = formatInkRefLabels(commit.refs);
25910
- const totalWidth = 140;
26060
+ // Total cells available to the row content. Earlier revisions used a
26061
+ // hardcoded 140 here, which let row content overflow whenever the
26062
+ // panel was narrower than that — Ink would wrap onto a second visual
26063
+ // line and the next commit's graph indicator landed against the wrap
26064
+ // continuation rather than its own commit (#830). Subtracting 4
26065
+ // accounts for the panel's left + right border + 1-cell padding.
26066
+ const totalWidth = Math.max(20, panelWidth - 4);
25911
26067
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
25912
- const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refs));
26068
+ // Refs trail the message and shrink first when the row is narrow:
26069
+ // the user can always see the full ref list in the inspector, so
26070
+ // the headline subject keeps priority over decoration.
26071
+ const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
26072
+ const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
26073
+ const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
25913
26074
  const message = truncate$1(commit.message, messageRoom);
25914
26075
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
25915
26076
  const accent = theme.noColor ? undefined : theme.colors.accent;
@@ -25924,7 +26085,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
25924
26085
  key: `${commit.hash}-${index}`,
25925
26086
  backgroundColor: selectedBg,
25926
26087
  inverse: selected,
25927
- }, ...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);
26088
+ }, ...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);
25928
26089
  }
25929
26090
  /**
25930
26091
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -26198,6 +26359,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26198
26359
  : `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
26199
26360
  const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
26200
26361
  const loadingLabel = formatLogInkLoading({ resource: 'branches' });
26362
+ // Per-column width derived from the visible window (#833) so columns
26363
+ // align across rows regardless of name length. Padded to the longest
26364
+ // name in view so short rows fill out instead of leaving a gutter;
26365
+ // capped at 40 cells so one runaway long branch name doesn't blow
26366
+ // out the timestamp column entirely (longer names get truncated and
26367
+ // the timestamp stays where the user expects it).
26368
+ const nameColWidth = visible.length === 0
26369
+ ? 28
26370
+ : Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
26201
26371
  const lines = loading
26202
26372
  ? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
26203
26373
  : localBranches.length === 0
@@ -26211,18 +26381,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26211
26381
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
26212
26382
  // Split the row into spans so the timestamp stays dim even on the
26213
26383
  // currently-selected (bold) row. The leading marker + name keep
26214
- // their original column widths; the timestamp is right-padded so
26215
- // the divergence column stays aligned across rows.
26216
- const namePadded = branch.shortName.padEnd(28);
26384
+ // their per-window-derived column widths; the timestamp is
26385
+ // right-padded so the divergence column stays aligned across rows.
26386
+ const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
26217
26387
  const timestampPadded = lastTouched.padEnd(8);
26218
26388
  const lineDim = !isSelected && !branch.current;
26219
26389
  const head = `${cursor} ${marker} ${namePadded} `;
26220
26390
  const trailingDivergence = divergence ? ` ${divergence}` : '';
26221
- // Truncate the assembled line cooperatively so we never overflow
26222
- // the panel; the timestamp is short and the divergence is the
26223
- // most expendable, but the existing 140 cap is ample.
26391
+ // Truncate the assembled line to the actual panel width so a
26392
+ // narrow inspector / sidebar focus doesn't push branch rows
26393
+ // onto a second visual line (#830).
26224
26394
  const fullText = `${head}${timestampPadded}${trailingDivergence}`;
26225
- const truncated = truncate$1(fullText, 140);
26395
+ const truncated = truncate$1(fullText, Math.max(20, width - 4));
26226
26396
  // If truncation chopped into the timestamp/divergence portion,
26227
26397
  // fall back to a single Text to keep the visible width honest.
26228
26398
  if (truncated !== fullText) {
@@ -26266,6 +26436,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26266
26436
  : `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
26267
26437
  const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
26268
26438
  const loadingLabel = formatLogInkLoading({ resource: 'tags' });
26439
+ // Per-window name column width (#833) so short tags don't leave a
26440
+ // wide gutter and long tags don't push the subject off-screen. Cap
26441
+ // matches the branches surface for visual consistency across the
26442
+ // promoted views.
26443
+ const tagNameColWidth = visible.length === 0
26444
+ ? 20
26445
+ : Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
26269
26446
  const lines = loading
26270
26447
  ? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
26271
26448
  : tags.length === 0
@@ -26279,8 +26456,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26279
26456
  // formatHyperlink wraps just the tag name, leaving width math
26280
26457
  // intact.
26281
26458
  const url = buildRefUrl(context.provider?.repository, tag.name);
26282
- const namePadded = tag.name.padEnd(20);
26283
- const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, 140);
26459
+ const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
26460
+ const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
26284
26461
  if (!url || lineText.indexOf(namePadded) < 0) {
26285
26462
  return h(Text, {
26286
26463
  key: `tag-${index}`,
@@ -26363,6 +26540,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26363
26540
  const headerRight = loading
26364
26541
  ? 'loading worktrees'
26365
26542
  : `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
26543
+ // Per-window branch column width (#833). Worktrees often track
26544
+ // branches with names varying widely in length (`main` vs.
26545
+ // `feat/tui-something-long`); fixed-width padding either left a
26546
+ // huge gutter on short rows or pushed the path column off-screen on
26547
+ // long ones. Cap matches the other promoted surfaces.
26548
+ const branchColWidth = visible.length === 0
26549
+ ? 28
26550
+ : Math.min(40, Math.max(8, ...visible.map((entry) => {
26551
+ const label = entry.branch ? entry.branch : entry.head || '<detached>';
26552
+ return label.length;
26553
+ })));
26366
26554
  const lines = loading
26367
26555
  ? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
26368
26556
  : worktrees.length === 0
@@ -26374,11 +26562,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26374
26562
  const marker = entry.current ? '*' : ' ';
26375
26563
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
26376
26564
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
26565
+ const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
26377
26566
  return h(Text, {
26378
26567
  key: `worktree-${index}`,
26379
26568
  bold: isSelected,
26380
26569
  dimColor: !isSelected && !entry.current,
26381
- }, truncate$1(`${cursor} ${marker} ${branchLabel.padEnd(28)} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26570
+ }, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26382
26571
  });
26383
26572
  return h(Box, {
26384
26573
  borderColor: focusBorderColor(theme, focused),
@@ -27458,6 +27647,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
27458
27647
  }, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
27459
27648
  }
27460
27649
 
27650
+ /**
27651
+ * Per-repo disk cache of the last successful commit-log fetch (#808).
27652
+ * Lets the TUI render an immediate stale-but-useful history view on
27653
+ * subsequent boots while the fresh `git log` runs in the background;
27654
+ * once the fresh data lands the runtime swaps it in transparently.
27655
+ *
27656
+ * Strict best-effort: read failures fall back to "no cache" (boot
27657
+ * shows the loading placeholder), and write failures are swallowed
27658
+ * silently (next boot just doesn't have the cache yet). The cache is
27659
+ * never load-bearing.
27660
+ *
27661
+ * Repos are keyed by a short hash of their absolute path. No PII in
27662
+ * the cache filename, and re-creating a repo at the same path keeps
27663
+ * the same cache.
27664
+ */
27665
+ const CACHE_SCHEMA_VERSION = 1;
27666
+ const CACHE_DIR_NAME = 'overview';
27667
+ /**
27668
+ * Hard cap on rows we'll write per cache entry. The interactive
27669
+ * default limit is 300; this caps growth in case a user opts into a
27670
+ * much larger window. Keeps the cache file under ~200kb on a typical
27671
+ * repo.
27672
+ */
27673
+ const CACHE_ROW_HARD_CAP = 500;
27674
+ function resolveCacheDir() {
27675
+ const xdg = process.env.XDG_CACHE_HOME;
27676
+ if (xdg && xdg.trim().length > 0) {
27677
+ return path$1.join(xdg, 'coco', CACHE_DIR_NAME);
27678
+ }
27679
+ return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
27680
+ }
27681
+ function repoKey(repoPath) {
27682
+ // sha1 here is a non-security cache-key derivation — we just need a
27683
+ // deterministic short identifier for the cache filename so two repos
27684
+ // at different paths never collide. No PII or auth context is hashed
27685
+ // and no collision-resistance against an adversary is required.
27686
+ // DevSkim DS126858 doesn't apply.
27687
+ // DevSkim: ignore DS126858
27688
+ return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
27689
+ }
27690
+ function getOverviewCachePath(repoPath) {
27691
+ return path$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
27692
+ }
27693
+ function readCachedCommits(repoPath) {
27694
+ try {
27695
+ const raw = fs$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
27696
+ const parsed = JSON.parse(raw);
27697
+ if (parsed.version !== CACHE_SCHEMA_VERSION) {
27698
+ // Schema mismatch — quietly drop the stale entry on next write.
27699
+ // Treating it as "no cache" keeps boot behavior predictable
27700
+ // across upgrades.
27701
+ return undefined;
27702
+ }
27703
+ if (!Array.isArray(parsed.rows)) {
27704
+ return undefined;
27705
+ }
27706
+ return parsed.rows;
27707
+ }
27708
+ catch {
27709
+ return undefined;
27710
+ }
27711
+ }
27712
+ function writeCachedCommits(repoPath, rows) {
27713
+ const file = getOverviewCachePath(repoPath);
27714
+ const envelope = {
27715
+ version: CACHE_SCHEMA_VERSION,
27716
+ savedAt: new Date().toISOString(),
27717
+ rows: rows.slice(0, CACHE_ROW_HARD_CAP),
27718
+ };
27719
+ try {
27720
+ fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
27721
+ fs$1.writeFileSync(file, JSON.stringify(envelope));
27722
+ }
27723
+ catch {
27724
+ // Best-effort persistence; swallow.
27725
+ }
27726
+ }
27727
+
27461
27728
  function createLogArgvFromUiArgv(argv) {
27462
27729
  return {
27463
27730
  $0: argv.$0,
@@ -27482,14 +27749,43 @@ function createUiTheme(config, argv) {
27482
27749
  preset: argv.theme,
27483
27750
  };
27484
27751
  }
27752
+ /**
27753
+ * Wrap a fresh-rows loader with the disk-cache write step. Lets the
27754
+ * runtime stay caching-agnostic — it just receives the rows and
27755
+ * doesn't know whether they came from cache or git, while the caller
27756
+ * (which knows the repo path) handles persistence.
27757
+ */
27758
+ function withCacheWrite(repoPath, loader) {
27759
+ return async () => {
27760
+ const rows = await loader();
27761
+ writeCachedCommits(repoPath, rows);
27762
+ return rows;
27763
+ };
27764
+ }
27485
27765
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
27486
27766
  const config = options.config || loadConfig(logArgv);
27487
27767
  const git = options.git || getRepo();
27488
- const rows = options.rows || (await getLogRows(git, logArgv));
27489
- await startInkInteractiveLog(git, rows, {}, {
27768
+ const repoPath = process.cwd();
27769
+ // Three-stage boot (#808):
27770
+ // 1. Read the disk cache and pass cached rows as the initial set
27771
+ // so the user sees the workstation chrome populated with
27772
+ // commits in the first frame.
27773
+ // 2. Mount Ink immediately with those rows (or [] if no cache).
27774
+ // 3. Run loadRows in the background to refresh — when fresh data
27775
+ // lands the runtime swaps it in transparently and we persist
27776
+ // the new rows back to the cache for next boot.
27777
+ // Caller-provided rows skip the lazy path entirely (caller already
27778
+ // has up-to-date data — no point redoing the fetch).
27779
+ const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
27780
+ const initialRows = options.rows || cachedRows || [];
27781
+ const loadRows = options.rows
27782
+ ? undefined
27783
+ : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
27784
+ await startInkInteractiveLog(git, initialRows, {}, {
27490
27785
  appLabel: 'coco',
27491
27786
  idleTips: config.logTui?.idleTips,
27492
27787
  initialView: 'history',
27788
+ loadRows,
27493
27789
  logArgv,
27494
27790
  theme: config.logTui?.theme,
27495
27791
  });
@@ -27498,11 +27794,15 @@ async function startCocoUi(argv) {
27498
27794
  const config = loadConfig(argv);
27499
27795
  const git = getRepo();
27500
27796
  const logArgv = createLogArgvFromUiArgv(argv);
27501
- const rows = await getLogRows(git, logArgv);
27502
- await startInkInteractiveLog(git, rows, {}, {
27797
+ const repoPath = process.cwd();
27798
+ // Same three-stage boot as startCocoUiFromLogArgv — mount with
27799
+ // cached rows for an instant-paint shell, refresh in background.
27800
+ const cachedRows = readCachedCommits(repoPath);
27801
+ await startInkInteractiveLog(git, cachedRows || [], {}, {
27503
27802
  appLabel: 'coco',
27504
27803
  idleTips: config.logTui?.idleTips,
27505
27804
  initialView: argv.view || 'history',
27805
+ loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
27506
27806
  logArgv,
27507
27807
  theme: createUiTheme(config, argv),
27508
27808
  });
@@ -27612,15 +27912,18 @@ const handler$2 = async (argv) => {
27612
27912
  });
27613
27913
  return;
27614
27914
  }
27615
- const rows = await getLogRows(git, argv);
27915
+ // Interactive path defers the commit log fetch into the runtime
27916
+ // (#808) so the TUI mounts immediately with a "Loading commits…"
27917
+ // placeholder. The non-interactive (stdout) path still needs rows
27918
+ // up-front because the formatter just dumps a static snapshot.
27616
27919
  if (argv.interactive && format === 'table') {
27617
27920
  await startCocoUiFromLogArgv(argv, {
27618
27921
  config,
27619
27922
  git,
27620
- rows,
27621
27923
  });
27622
27924
  return;
27623
27925
  }
27926
+ const rows = await getLogRows(git, argv);
27624
27927
  const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
27625
27928
  await handleResult({
27626
27929
  result,