git-coco 0.54.1 → 0.55.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 +1493 -515
  2. package/dist/index.js +1493 -515
  3. package/package.json +1 -1
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.54.1";
81
+ const BUILD_VERSION = "0.55.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -18368,7 +18368,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
18368
18368
  const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
18369
18369
  const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
18370
18370
  const LOG_DEFAULT_LIMIT = 30;
18371
- const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
18371
+ // Bumped from 300 → 1000 in 0.54.2. With the full-graph default
18372
+ // (#1034) the workstation surfaces many more refs (all branches, all
18373
+ // tags, plus stash commits added via `extraRefs`), and on active repos
18374
+ // the 300-commit cap was cutting off year+-old stash bases and old
18375
+ // tag commits — making the cursor-syncs-history effect report "tip
18376
+ // not in loaded window" instead of moving the graph cursor. 1000
18377
+ // fits a year of activity for most repos, git log is still sub-200ms,
18378
+ // and Ink virtualises scroll so render cost stays flat.
18379
+ const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
18372
18380
  function toArray(value) {
18373
18381
  if (!value) {
18374
18382
  return [];
@@ -18548,12 +18556,77 @@ function buildLogArgs(argv, options = {}) {
18548
18556
  else if (argv.branch) {
18549
18557
  args.push(argv.branch);
18550
18558
  }
18559
+ // Extra refs (stash commits etc.) — append after the --all / branch
18560
+ // selector but BEFORE the path separator. Git treats them as
18561
+ // additional graph roots, so the traversal includes them alongside
18562
+ // whatever --all / --branch already covers.
18563
+ if (options.extraRefs && options.extraRefs.length > 0) {
18564
+ args.push(...options.extraRefs);
18565
+ }
18551
18566
  const paths = toArray(argv.path);
18552
18567
  if (paths.length > 0) {
18553
18568
  args.push('--', ...paths);
18554
18569
  }
18555
18570
  return args;
18556
18571
  }
18572
+ /**
18573
+ * Default size of a targeted-context window. Sized to comfortably
18574
+ * cover a year of activity on most repos so the cursor-sync's
18575
+ * "jump to commit anchored on a ref I just selected" can succeed
18576
+ * without paginating through the whole history.
18577
+ */
18578
+ const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
18579
+ /**
18580
+ * Load a window of commits anchored on a specific hash. Used by the
18581
+ * cursor-sync effect when the user selects a ref (branch / tag /
18582
+ * stash) whose target commit isn't in the loaded graph window.
18583
+ *
18584
+ * Critical detail: this walks **only from the target** (and its
18585
+ * ancestors), NOT from `--all`. Why: when you combine `--all` with
18586
+ * `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
18587
+ * the result by date, and slices the newest N rows. If the target
18588
+ * is older than the Nth newest commit across all refs (very common
18589
+ * for year-old tags / branches on active repos), it falls off the
18590
+ * slice even though it was passed as a root. Walking from the
18591
+ * target alone guarantees the target IS the first row of the
18592
+ * output and its ancestors fill the rest.
18593
+ *
18594
+ * The caller merges the result via the `appendRows` reducer action
18595
+ * which deduplicates by hash, so the target's ancestry slots into
18596
+ * the existing `--all` graph cleanly. The user's loaded view ends
18597
+ * up as the union of: the original `--all` window + target's
18598
+ * ancestry — exactly what's needed for the cursor to land.
18599
+ *
18600
+ * Capped at `options.limit` (default 5000) to keep one targeted
18601
+ * fetch bounded. For most refs, even a 100-commit limit would be
18602
+ * enough to surface the target; we go higher to also pull in the
18603
+ * surrounding context so the user can scroll around the landed
18604
+ * cursor.
18605
+ */
18606
+ async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
18607
+ // Strip every "walk many refs" toggle so buildLogArgs produces a
18608
+ // clean `git log <flags> <targetHash>` — exactly the walk that
18609
+ // guarantees the target's inclusion.
18610
+ const merged = {
18611
+ ...argv,
18612
+ all: false,
18613
+ view: 'compact', // suppresses 'full' → '--all' mapping
18614
+ branch: undefined,
18615
+ path: undefined,
18616
+ };
18617
+ // Also drop --first-parent / --no-merges so the target's ancestry
18618
+ // renders with full topology (matters for stash commits which are
18619
+ // merges by construction).
18620
+ const baseArgs = buildLogArgs(merged, {
18621
+ limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
18622
+ }).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
18623
+ // Splice the target as the positional ref. `buildLogArgs` already
18624
+ // appended any `--all`/`--branch`/`<extraRefs>` it considered;
18625
+ // since we cleared all those above, the only positional ref we
18626
+ // add is the target.
18627
+ baseArgs.push(targetHash);
18628
+ return parseLogOutput(await git.raw(baseArgs));
18629
+ }
18557
18630
  /**
18558
18631
  * Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
18559
18632
  *
@@ -18661,6 +18734,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18661
18734
  };
18662
18735
  }
18663
18736
 
18737
+ function parseStashSubject(subject) {
18738
+ const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18739
+ if (!match) {
18740
+ return {
18741
+ branch: '<unknown>',
18742
+ message: subject,
18743
+ };
18744
+ }
18745
+ return {
18746
+ branch: match[1],
18747
+ message: match[2] || subject,
18748
+ };
18749
+ }
18750
+ function parseStashList(output) {
18751
+ return output
18752
+ .split('\n')
18753
+ .map((line) => line.trim())
18754
+ .filter(Boolean)
18755
+ .map((line) => {
18756
+ const [ref, hash, parents, date, subject] = line.split('\x1f');
18757
+ const parsedSubject = parseStashSubject(subject || '');
18758
+ // `%P` returns space-separated parent hashes. Stash commits are
18759
+ // merges with 2-3 parents; the FIRST is the base (HEAD at stash
18760
+ // time). Empty parents string (legacy / corrupted entries) maps
18761
+ // to an empty baseHash; the cursor-sync caller treats that as
18762
+ // "no base available, fall back to stash hash."
18763
+ const baseHash = parents ? (parents.split(' ')[0] || '') : '';
18764
+ return {
18765
+ ref,
18766
+ hash,
18767
+ baseHash,
18768
+ date,
18769
+ branch: parsedSubject.branch,
18770
+ message: parsedSubject.message,
18771
+ };
18772
+ });
18773
+ }
18774
+ function parseStashFiles(output) {
18775
+ return output
18776
+ .split('\n')
18777
+ .map((line) => line.trim())
18778
+ .filter(Boolean);
18779
+ }
18780
+ /**
18781
+ * Resolve the commit hashes for every stash, in `stash@{N}` order.
18782
+ *
18783
+ * Used by the workstation's history loader to include older stashes
18784
+ * as graph roots — `git log --all` only walks `refs/stash` (the
18785
+ * latest stash) by default, so stash@{1+} commits live off-graph
18786
+ * unless explicitly referenced. Passing this list as positional refs
18787
+ * to `git log` makes every stash appear as a graph node, which lets
18788
+ * the cursor-syncs-history effect actually land on them when the
18789
+ * user navigates the stashes sidebar.
18790
+ *
18791
+ * Cheap: one `git stash list` call, no per-stash fan-out. Returns
18792
+ * an empty array when there are no stashes — callers can pass the
18793
+ * result through unconditionally.
18794
+ */
18795
+ async function getStashCommitHashes(git) {
18796
+ const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
18797
+ return raw
18798
+ .split('\n')
18799
+ .map((line) => line.trim())
18800
+ .filter(Boolean);
18801
+ }
18802
+ async function getStashOverview(git) {
18803
+ // Format fields (separated by 0x1f / unit separator):
18804
+ // %gd — stash reflog selector (stash@{N})
18805
+ // %H — stash commit hash
18806
+ // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18807
+ // %ci — committer date, ISO format
18808
+ // %gs — reflog subject ("WIP on main: <subject>")
18809
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
18810
+ return {
18811
+ stashes: await Promise.all(stashes.map(async (stash) => ({
18812
+ ...stash,
18813
+ files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18814
+ }))),
18815
+ };
18816
+ }
18817
+ /**
18818
+ * Full unified-patch diff for a stash. Used by the diff surface when
18819
+ * `state.diffSource === 'stash'` to render the stash's changes inline.
18820
+ *
18821
+ * Empty stashes (e.g. created by `git stash --keep-index` against an
18822
+ * already-clean tree) return [] rather than throwing — surfaces fall
18823
+ * back to a "no diff to display" message.
18824
+ */
18825
+ async function getStashDiff(git, stashRef) {
18826
+ return (await git.raw(['stash', 'show', '-p', stashRef]))
18827
+ .split('\n')
18828
+ .map((line) => line.replace(/\r$/, ''));
18829
+ }
18830
+ /**
18831
+ * Slice a unified-patch into per-file sections. Each entry records the
18832
+ * file path and the offset of its `diff --git` header within `lines`.
18833
+ * Used by the stash explorer to build a per-file cursor + cherry-pick
18834
+ * the file at the cursor.
18835
+ *
18836
+ * Renames / moves return the destination path (the `b/` side); the
18837
+ * action surface treats that as the path to materialize from the stash.
18838
+ *
18839
+ * Path quoting: git wraps paths containing spaces or special characters
18840
+ * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18841
+ * The parser handles both the unquoted and quoted forms; without that,
18842
+ * stash-file navigation and cherry-pick silently broke for any file
18843
+ * whose path contained a space.
18844
+ */
18845
+ function parseStashDiffFiles(lines) {
18846
+ const files = [];
18847
+ for (let i = 0; i < lines.length; i += 1) {
18848
+ const line = lines[i];
18849
+ const parsed = parseDiffGitHeader(line);
18850
+ if (parsed) {
18851
+ files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18852
+ }
18853
+ }
18854
+ return files;
18855
+ }
18856
+ /**
18857
+ * Resolve which stash file *contains* a given line offset — the user's
18858
+ * cursor scrolls through a concatenated multi-file patch, and this is
18859
+ * what powers the "File N/M: <path>" panel header, the inline header
18860
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18861
+ * dispatchers' "what file is the cursor on" lookup.
18862
+ *
18863
+ * Returns `undefined` when the file list is empty *or* the offset
18864
+ * lands before the very first file's `diff --git` header (e.g. when
18865
+ * `--stat` summary lines lead the patch). Callers fall through to a
18866
+ * "no file selected" state in that case.
18867
+ */
18868
+ function findStashFileForOffset(files, offset) {
18869
+ if (files.length === 0)
18870
+ return undefined;
18871
+ let current;
18872
+ for (const file of files) {
18873
+ if (file.startLine <= offset) {
18874
+ current = file;
18875
+ }
18876
+ else {
18877
+ break;
18878
+ }
18879
+ }
18880
+ // First file is the canonical fallback — even if the offset lands
18881
+ // before its header (rare), we want the cursor to be "in" something
18882
+ // so the user's actions have a target.
18883
+ return current ?? files[0];
18884
+ }
18885
+ const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18886
+ function parseDiffGitHeader(line) {
18887
+ const match = line.match(DIFF_GIT_HEADER);
18888
+ if (!match)
18889
+ return undefined;
18890
+ const aPath = unescapeGitQuoted(match[1]) || match[2];
18891
+ const bPath = unescapeGitQuoted(match[3]) || match[4];
18892
+ if (!aPath || !bPath)
18893
+ return undefined;
18894
+ return { aPath, bPath };
18895
+ }
18896
+ function unescapeGitQuoted(value) {
18897
+ if (value === undefined)
18898
+ return undefined;
18899
+ // Git's diff header quoting escapes `"`, `\`, and the usual
18900
+ // C-style sequences. Reverse the most common ones so callers get the
18901
+ // raw on-disk path.
18902
+ return value
18903
+ .replace(/\\\\/g, '\\')
18904
+ .replace(/\\"/g, '"')
18905
+ .replace(/\\t/g, '\t')
18906
+ .replace(/\\n/g, '\n');
18907
+ }
18908
+
18664
18909
  const FIELD_SEPARATOR$2 = '\x1f';
18665
18910
  function parseBranchRefs(output) {
18666
18911
  return output
@@ -18853,143 +19098,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18853
19098
  }
18854
19099
  }
18855
19100
 
18856
- function parseStashSubject(subject) {
18857
- const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18858
- if (!match) {
18859
- return {
18860
- branch: '<unknown>',
18861
- message: subject,
18862
- };
18863
- }
18864
- return {
18865
- branch: match[1],
18866
- message: match[2] || subject,
18867
- };
18868
- }
18869
- function parseStashList(output) {
18870
- return output
18871
- .split('\n')
18872
- .map((line) => line.trim())
18873
- .filter(Boolean)
18874
- .map((line) => {
18875
- const [ref, hash, date, subject] = line.split('\x1f');
18876
- const parsedSubject = parseStashSubject(subject || '');
18877
- return {
18878
- ref,
18879
- hash,
18880
- date,
18881
- branch: parsedSubject.branch,
18882
- message: parsedSubject.message,
18883
- };
18884
- });
18885
- }
18886
- function parseStashFiles(output) {
18887
- return output
18888
- .split('\n')
18889
- .map((line) => line.trim())
18890
- .filter(Boolean);
18891
- }
18892
- async function getStashOverview(git) {
18893
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
18894
- return {
18895
- stashes: await Promise.all(stashes.map(async (stash) => ({
18896
- ...stash,
18897
- files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18898
- }))),
18899
- };
18900
- }
18901
- /**
18902
- * Full unified-patch diff for a stash. Used by the diff surface when
18903
- * `state.diffSource === 'stash'` to render the stash's changes inline.
18904
- *
18905
- * Empty stashes (e.g. created by `git stash --keep-index` against an
18906
- * already-clean tree) return [] rather than throwing — surfaces fall
18907
- * back to a "no diff to display" message.
18908
- */
18909
- async function getStashDiff(git, stashRef) {
18910
- return (await git.raw(['stash', 'show', '-p', stashRef]))
18911
- .split('\n')
18912
- .map((line) => line.replace(/\r$/, ''));
18913
- }
18914
- /**
18915
- * Slice a unified-patch into per-file sections. Each entry records the
18916
- * file path and the offset of its `diff --git` header within `lines`.
18917
- * Used by the stash explorer to build a per-file cursor + cherry-pick
18918
- * the file at the cursor.
18919
- *
18920
- * Renames / moves return the destination path (the `b/` side); the
18921
- * action surface treats that as the path to materialize from the stash.
18922
- *
18923
- * Path quoting: git wraps paths containing spaces or special characters
18924
- * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18925
- * The parser handles both the unquoted and quoted forms; without that,
18926
- * stash-file navigation and cherry-pick silently broke for any file
18927
- * whose path contained a space.
18928
- */
18929
- function parseStashDiffFiles(lines) {
18930
- const files = [];
18931
- for (let i = 0; i < lines.length; i += 1) {
18932
- const line = lines[i];
18933
- const parsed = parseDiffGitHeader(line);
18934
- if (parsed) {
18935
- files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18936
- }
18937
- }
18938
- return files;
18939
- }
18940
- /**
18941
- * Resolve which stash file *contains* a given line offset — the user's
18942
- * cursor scrolls through a concatenated multi-file patch, and this is
18943
- * what powers the "File N/M: <path>" panel header, the inline header
18944
- * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18945
- * dispatchers' "what file is the cursor on" lookup.
18946
- *
18947
- * Returns `undefined` when the file list is empty *or* the offset
18948
- * lands before the very first file's `diff --git` header (e.g. when
18949
- * `--stat` summary lines lead the patch). Callers fall through to a
18950
- * "no file selected" state in that case.
18951
- */
18952
- function findStashFileForOffset(files, offset) {
18953
- if (files.length === 0)
18954
- return undefined;
18955
- let current;
18956
- for (const file of files) {
18957
- if (file.startLine <= offset) {
18958
- current = file;
18959
- }
18960
- else {
18961
- break;
18962
- }
18963
- }
18964
- // First file is the canonical fallback — even if the offset lands
18965
- // before its header (rare), we want the cursor to be "in" something
18966
- // so the user's actions have a target.
18967
- return current ?? files[0];
18968
- }
18969
- const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18970
- function parseDiffGitHeader(line) {
18971
- const match = line.match(DIFF_GIT_HEADER);
18972
- if (!match)
18973
- return undefined;
18974
- const aPath = unescapeGitQuoted(match[1]) || match[2];
18975
- const bPath = unescapeGitQuoted(match[3]) || match[4];
18976
- if (!aPath || !bPath)
18977
- return undefined;
18978
- return { aPath, bPath };
18979
- }
18980
- function unescapeGitQuoted(value) {
18981
- if (value === undefined)
18982
- return undefined;
18983
- // Git's diff header quoting escapes `"`, `\`, and the usual
18984
- // C-style sequences. Reverse the most common ones so callers get the
18985
- // raw on-disk path.
18986
- return value
18987
- .replace(/\\\\/g, '\\')
18988
- .replace(/\\"/g, '"')
18989
- .replace(/\\t/g, '\t')
18990
- .replace(/\\n/g, '\n');
18991
- }
18992
-
18993
19101
  function fileState(indexStatus, worktreeStatus) {
18994
19102
  if (indexStatus === '?' && worktreeStatus === '?') {
18995
19103
  return 'untracked';
@@ -19056,7 +19164,18 @@ function parseTagRefs(output) {
19056
19164
  .map((line) => line.trimEnd())
19057
19165
  .filter(Boolean)
19058
19166
  .map((line) => {
19059
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
19167
+ const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
19168
+ // For annotated tags `%(objectname:short)` returns the TAG
19169
+ // OBJECT's SHA, not the commit it points to — that's the SHA
19170
+ // sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
19171
+ // dereferences the tag and yields the commit's SHA, but is
19172
+ // EMPTY for lightweight tags (which are already direct
19173
+ // pointers to commits). Prefer the dereferenced form when
19174
+ // present, fall back to the object SHA otherwise. This is what
19175
+ // lets cursor-sync find the tagged commit in the loaded log
19176
+ // window — anchoring on the tag object's own SHA would never
19177
+ // match a commit row.
19178
+ const hash = derefedHash || objectHash;
19060
19179
  return {
19061
19180
  name,
19062
19181
  hash,
@@ -19068,7 +19187,7 @@ function parseTagRefs(output) {
19068
19187
  async function getTagOverview(git) {
19069
19188
  const output = await git.raw([
19070
19189
  'for-each-ref',
19071
- `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19190
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19072
19191
  '--sort=-creatordate',
19073
19192
  'refs/tags',
19074
19193
  ]);
@@ -19950,6 +20069,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
19950
20069
  output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
19951
20070
  }
19952
20071
 
20072
+ /**
20073
+ * Shared hash-matching helpers for cross-command lookups.
20074
+ *
20075
+ * Git surfaces the same commit with different short-hash lengths
20076
+ * depending on which command produced the row:
20077
+ *
20078
+ * - `for-each-ref --format=%(objectname:short)` (branches, tags,
20079
+ * stashes) honors `core.abbrev`, typically 7 chars.
20080
+ * - `git log --pretty=format:%h` (history rows) honors the same
20081
+ * setting BUT git auto-extends abbreviations to keep them unique
20082
+ * within the walked set — so the same commit can come back as 7
20083
+ * chars from one command and 8 (or more) from another.
20084
+ *
20085
+ * Consequence: any exact-equality lookup that compares a hash from
20086
+ * `for-each-ref` against a hash from `git log` will miss the match
20087
+ * even when both refer to the same commit. This bit the workstation's
20088
+ * cursor-sync effect twice during 0.54.2 — once in the resolver, once
20089
+ * in the `selectCommitByHash` reducer — and shows up wherever a ref
20090
+ * hash is checked against the loaded log window.
20091
+ *
20092
+ * The fix is bidirectional prefix matching: a hash matches another if
20093
+ * one is a prefix of the other. Below a 4-char floor we refuse to
20094
+ * match — three chars would collide with too many real commits.
20095
+ *
20096
+ * This module is the canonical place for that logic. Import it
20097
+ * anywhere you compare a "hash from one git formatter" against a
20098
+ * "hash from a different git formatter."
20099
+ *
20100
+ * Lives in `src/git/` because both `workstation/` and `commands/log/`
20101
+ * depend on it — `commands/log/` must not depend on `workstation/`,
20102
+ * so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
20103
+ */
20104
+ /**
20105
+ * Minimum length below which we refuse to prefix-match. Three chars
20106
+ * is too small to be a meaningful unique prefix for any real-world
20107
+ * git history.
20108
+ */
20109
+ const MIN_PREFIX_LENGTH = 4;
20110
+ /**
20111
+ * True when `a` and `b` refer to the same commit, tolerating
20112
+ * short-hash length differences from different git formatters.
20113
+ *
20114
+ * Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
20115
+ * string equality wins immediately (the common path); otherwise we
20116
+ * test bidirectional `startsWith` and bail when either input is too
20117
+ * short to be a meaningful prefix.
20118
+ */
20119
+ function hashesMatch(a, b) {
20120
+ if (!a || !b)
20121
+ return false;
20122
+ if (a === b)
20123
+ return true;
20124
+ if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
20125
+ return false;
20126
+ return a.startsWith(b) || b.startsWith(a);
20127
+ }
20128
+ /**
20129
+ * True when `hash` matches any entry in `candidates`. Convenience
20130
+ * wrapper for the common "is this ref's hash in any of the row's
20131
+ * hash variants?" check.
20132
+ */
20133
+ function hashesMatchAny(hash, candidates) {
20134
+ if (!hash)
20135
+ return false;
20136
+ return candidates.some((candidate) => hashesMatch(hash, candidate));
20137
+ }
20138
+ /**
20139
+ * True when `hash` is present in the loaded set — exact match first
20140
+ * (the O(1) fast path), then bidirectional `startsWith` over the set
20141
+ * to cover the formatter mismatch.
20142
+ *
20143
+ * The set is small in practice (1k–5k entries) so O(N) iteration on
20144
+ * miss is fine.
20145
+ */
20146
+ function hashLoaded(hash, loaded) {
20147
+ if (loaded.has(hash))
20148
+ return true;
20149
+ if (hash.length < MIN_PREFIX_LENGTH)
20150
+ return false;
20151
+ for (const entry of loaded) {
20152
+ if (entry.startsWith(hash) || hash.startsWith(entry))
20153
+ return true;
20154
+ }
20155
+ return false;
20156
+ }
20157
+
19953
20158
  const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
19954
20159
  /**
19955
20160
  * Parse a single `.gitattributes` body into the LFS-tracked
@@ -23642,7 +23847,16 @@ function createLogInkState(rows, options = {}) {
23642
23847
  worktreeDiffOffset: 0,
23643
23848
  filter: '',
23644
23849
  filterMode: false,
23645
- fullGraph: false,
23850
+ // Default to the full multi-ref graph (`git log --all`) so users
23851
+ // see how branches, tags, and stashes weave through the history
23852
+ // out of the box. Pre-0.54.x this defaulted to false (current
23853
+ // branch only); user feedback consistently asked for the
23854
+ // GitKraken-style "see everything" view as the starting state.
23855
+ // The `\` toggle still flips back to compact / current-branch
23856
+ // mode for users who want the cleaner single-line graph. Tests
23857
+ // override via `options.fullGraph` when they need the compact
23858
+ // case explicitly.
23859
+ fullGraph: options.fullGraph ?? true,
23646
23860
  showHelp: false,
23647
23861
  helpScrollOffset: 0,
23648
23862
  showCommandPalette: false,
@@ -23751,8 +23965,17 @@ function applyLogInkAction(state, action) {
23751
23965
  // branch's tip without the user manually scrolling. No-op when
23752
23966
  // the hash isn't in the loaded list (the runtime surfaces a
23753
23967
  // status hint in that case).
23968
+ //
23969
+ // Uses the shared `hashesMatchAny` helper to cover the
23970
+ // short-hash auto-extension mismatch between
23971
+ // `for-each-ref --format=%(objectname:short)` (cursored ref)
23972
+ // and `git log --pretty=format:%h` (history row). Without that
23973
+ // tolerance the resolver could decide "jump" but this reducer
23974
+ // would silently no-op — the status updates but the cursor
23975
+ // doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
23976
+ // testing. See `src/git/hashes.ts` for the matching rules.
23754
23977
  const target = action.hash;
23755
- const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
23978
+ const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
23756
23979
  if (index < 0) {
23757
23980
  return state;
23758
23981
  }
@@ -24689,7 +24912,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24689
24912
  const commit = state.filteredCommits[state.selectedIndex];
24690
24913
  const requireCommit = (fn) => {
24691
24914
  if (!commit) {
24692
- return [action({ type: 'setStatus', value: 'No commit selected' })];
24915
+ return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
24693
24916
  }
24694
24917
  return fn(commit.hash, state.selectedIndex);
24695
24918
  };
@@ -24728,6 +24951,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24728
24951
  return [action({
24729
24952
  type: 'setStatus',
24730
24953
  value: `Action ${inspectorAction.key} not yet wired`,
24954
+ kind: 'warning',
24731
24955
  })];
24732
24956
  }
24733
24957
  }
@@ -24942,6 +25166,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24942
25166
  return [action({
24943
25167
  type: 'setStatus',
24944
25168
  value: 'open the diff view and press [ or ] to jump hunks',
25169
+ kind: 'warning',
24945
25170
  })];
24946
25171
  case 'focusNext':
24947
25172
  return [action({ type: 'focusNext' })];
@@ -24990,6 +25215,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24990
25215
  return [action({
24991
25216
  type: 'setStatus',
24992
25217
  value: 'open branches / tags / history and press m on the cursored ref',
25218
+ kind: 'warning',
24993
25219
  })];
24994
25220
  case 'navigateBack':
24995
25221
  // Mirror the Esc / `<` semantics (#931): drain the frame's view
@@ -25065,6 +25291,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
25065
25291
  return [action({
25066
25292
  type: 'setStatus',
25067
25293
  value: 'Sort cycle is available in the branches and tags views',
25294
+ kind: 'warning',
25068
25295
  })];
25069
25296
  case 'yankClipboard':
25070
25297
  // The runtime resolves the value/label against the live filtered
@@ -25121,7 +25348,7 @@ function submitInputPrompt(state) {
25121
25348
  return [];
25122
25349
  const value = state.inputPrompt.value.trim();
25123
25350
  if (!value) {
25124
- return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
25351
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
25125
25352
  }
25126
25353
  if (state.inputPrompt.kind === 'reset-mode') {
25127
25354
  const mode = value.toLowerCase();
@@ -25129,6 +25356,7 @@ function submitInputPrompt(state) {
25129
25356
  return [action({
25130
25357
  type: 'setStatus',
25131
25358
  value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
25359
+ kind: 'warning',
25132
25360
  })];
25133
25361
  }
25134
25362
  return [
@@ -25142,6 +25370,7 @@ function submitInputPrompt(state) {
25142
25370
  return [action({
25143
25371
  type: 'setStatus',
25144
25372
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25373
+ kind: 'warning',
25145
25374
  })];
25146
25375
  }
25147
25376
  return [
@@ -25205,6 +25434,7 @@ function submitInputPrompt(state) {
25205
25434
  return [action({
25206
25435
  type: 'setStatus',
25207
25436
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25437
+ kind: 'warning',
25208
25438
  })];
25209
25439
  }
25210
25440
  return [
@@ -25802,7 +26032,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25802
26032
  }
25803
26033
  return [
25804
26034
  action({ type: 'setPendingKey', value: undefined }),
25805
- action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
26035
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
25806
26036
  ];
25807
26037
  }
25808
26038
  // `gT` chord: create a lightweight tag at the cursored commit on the
@@ -25826,7 +26056,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25826
26056
  }
25827
26057
  return [
25828
26058
  action({ type: 'setPendingKey', value: undefined }),
25829
- action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
26059
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
25830
26060
  ];
25831
26061
  }
25832
26062
  // #784 — bisect view action keys. Scoped to `state.activeView ===
@@ -25941,6 +26171,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25941
26171
  value: next === 'split'
25942
26172
  ? 'Switched to side-by-side diff'
25943
26173
  : 'Switched to unified diff',
26174
+ kind: 'success',
25944
26175
  }),
25945
26176
  ];
25946
26177
  }
@@ -26391,10 +26622,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26391
26622
  if (key.return && state.compareBase && isCompareFlowTarget(state)) {
26392
26623
  const head = getCursoredCompareRef(state, context);
26393
26624
  if (!head) {
26394
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26625
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26395
26626
  }
26396
26627
  if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
26397
- return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
26628
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
26398
26629
  }
26399
26630
  return [
26400
26631
  action({
@@ -26597,7 +26828,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26597
26828
  action({ type: 'setFocus', value: 'commits' }),
26598
26829
  ];
26599
26830
  }
26600
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
26831
+ return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
26601
26832
  }
26602
26833
  // Fall through — per-entity Enter handler below claims the keystroke.
26603
26834
  }
@@ -26708,7 +26939,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26708
26939
  if (inputValue === 'm' && isCompareFlowTarget(state)) {
26709
26940
  const ref = getCursoredCompareRef(state, context);
26710
26941
  if (!ref) {
26711
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26942
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26712
26943
  }
26713
26944
  if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
26714
26945
  return [
@@ -26939,7 +27170,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26939
27170
  // Always intercept `C` on the conflicts view to prevent fallthrough to
26940
27171
  // the global `C` (Create PR) binding when conflicts remain.
26941
27172
  if (inputValue === 'C' && state.activeView === 'conflicts') {
26942
- return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
27173
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
26943
27174
  }
26944
27175
  // Global `C` — create a pull request from the current branch. The
26945
27176
  // runtime callback handles pre-flight (current branch resolution,
@@ -26955,6 +27186,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26955
27186
  return [action({
26956
27187
  type: 'setStatus',
26957
27188
  value: 'Finish or cancel the commit draft before creating a PR.',
27189
+ kind: 'warning',
26958
27190
  })];
26959
27191
  }
26960
27192
  if (inputValue === 'C' && state.activeView !== 'conflicts') {
@@ -27014,7 +27246,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27014
27246
  return events;
27015
27247
  }
27016
27248
  if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
27017
- return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
27249
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
27018
27250
  }
27019
27251
  }
27020
27252
  // `c` on the history view cherry-picks the full selected commit on
@@ -27899,7 +28131,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
27899
28131
  rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
27900
28132
  tight: { min: 22, max: 28, fraction: 0.24 },
27901
28133
  normal: { min: 22, max: 30, fraction: 0.22 },
27902
- wide: { min: 28, max: 48, fraction: 0.24 },
28134
+ wide: { min: 28, max: 32, fraction: 0.20 },
27903
28135
  };
27904
28136
  function calcSidebarAtRestWidth(columns, density) {
27905
28137
  const config = SIDEBAR_AT_REST_BY_TIER[density];
@@ -29852,18 +30084,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
29852
30084
  ];
29853
30085
  }
29854
30086
 
30087
+ function resolveCursorSyncDecision(input) {
30088
+ if (!input.target) {
30089
+ return { type: 'noop', reason: 'no-target' };
30090
+ }
30091
+ if (input.target.hash === input.lastSyncedHash) {
30092
+ return { type: 'noop', reason: 'duplicate-of-last' };
30093
+ }
30094
+ if (isHashLoaded(input.target.hash, input.loadedHashes)) {
30095
+ return {
30096
+ type: 'jump',
30097
+ hash: input.target.hash,
30098
+ label: input.target.label,
30099
+ };
30100
+ }
30101
+ if (input.attemptedContextHashes.has(input.target.hash)) {
30102
+ return { type: 'unreachable', target: input.target };
30103
+ }
30104
+ return { type: 'load-context', target: input.target };
30105
+ }
30106
+ /**
30107
+ * Re-export of the shared `hashLoaded` helper under the resolver's
30108
+ * historical name. Kept exported so existing tests (and any external
30109
+ * importers) keep working unchanged — see `src/git/hashes.ts` for the
30110
+ * canonical implementation and the rationale behind bidirectional
30111
+ * prefix matching.
30112
+ */
30113
+ function isHashLoaded(hash, loadedHashes) {
30114
+ return hashLoaded(hash, loadedHashes);
30115
+ }
30116
+ /**
30117
+ * Build the membership set the resolver expects. Includes BOTH the
30118
+ * full hash and the short hash for every commit so the caller can
30119
+ * match either form (refs sometimes carry only the short hash and
30120
+ * `state.filteredCommits` items always have both).
30121
+ *
30122
+ * Exported so the cursor-sync effect can build the set once per
30123
+ * re-render and pass it down without leaking the implementation
30124
+ * detail. Tests use it to construct realistic inputs without
30125
+ * hand-rolling the dual-hash logic.
30126
+ */
30127
+ function buildLoadedHashSet(commits) {
30128
+ const set = new Set();
30129
+ for (const commit of commits) {
30130
+ if (commit.hash)
30131
+ set.add(commit.hash);
30132
+ if (commit.shortHash)
30133
+ set.add(commit.shortHash);
30134
+ }
30135
+ return set;
30136
+ }
30137
+
29855
30138
  /**
29856
- * Status-bar / footer renderer. Two-column layout:
29857
- * - Left: contextual hints for the active view (built by inkKeymap's
29858
- * `getLogInkFooterHints`), with the optional status message / idle
29859
- * tip appended.
29860
- * - Right: global key hints (`?` help, `:` palette, `q` quit, …).
30139
+ * Status-bar / footer renderer. Two-row layout, using the full
30140
+ * `height: 2` the footer already reserves:
30141
+ *
30142
+ * Row 1 — keyboard hint band:
30143
+ * ┌──── contextual hints ────┐ ┌──── globals ────┐
30144
+ * ↑/↓ branches ←/→ tab … ? help · : cmds · q
30145
+ *
30146
+ * Row 2 — status / feedback band:
30147
+ * ⠋ main has no upstream — nothing to fetch.
30148
+ *
30149
+ * Row 2 is empty when there's no status message, idle tip, or error.
30150
+ * This is a behaviour change from the pre-0.54.2 single-row layout
30151
+ * where the status message sat awkwardly between the contextual and
30152
+ * global hints, getting visually crushed.
29861
30153
  *
29862
- * Idle tips only fill the slot when no real status message is set so the
30154
+ * The separation matters because:
30155
+ * - status text and key hints serve different cognitive purposes
30156
+ * (read vs. scan) and competing for the same row makes both
30157
+ * harder to use,
30158
+ * - long status messages (especially errors / multi-clause loading
30159
+ * copy) no longer push global hints off screen or wrap into the
30160
+ * hint cluster,
30161
+ * - errors now keep the global hints visible — the user often
30162
+ * needs `?` / `:` / `q` to *recover* from the error.
30163
+ *
30164
+ * Idle tips fill row 2 only when no real status message is set so the
29863
30165
  * tip cycle never overwrites genuine workflow feedback.
29864
30166
  *
29865
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
29866
- * of #890. No behavior change.
30167
+ * Row 2 styling is kind-aware. Each statusKind gets its own theme
30168
+ * color and glyph prefix so the message is identifiable at a glance
30169
+ * — even with NO_COLOR set, the glyph alone communicates kind:
30170
+ *
30171
+ * loading → spinner + accent + bold
30172
+ * error → ✗ / ! + danger + bold
30173
+ * warning → ⚠ / ! + warning + bold
30174
+ * success → ✓ / + + success + bold
30175
+ * info → ℹ / i + info + bold
30176
+ * idle tip → no glyph + dim muted (passive)
30177
+ *
30178
+ * Pre-redesign success and loading both used `accent` (cyan), so the
30179
+ * user couldn't tell "done" from "in progress" by color alone. Each
30180
+ * kind now uses its dedicated theme color and ships an ASCII glyph
30181
+ * fallback for `theme.ascii` mode (TERM=dumb / vt100).
30182
+ *
30183
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30184
+ * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
30185
+ * colors + glyphs added in the same pass.
29867
30186
  */
29868
30187
  function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
29869
30188
  const { Box, Text } = components;
@@ -29895,50 +30214,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
29895
30214
  });
29896
30215
  // Real status messages always win; idle tips only fill the slot when it
29897
30216
  // would otherwise be empty.
29898
- const isLoading = Boolean(state.statusLoading && state.statusMessage);
29899
- const trailing = state.statusMessage || idleTip || '';
29900
- // Loading status gets a spinner prefix in front of the message —
29901
- // motion makes transient LLM calls (create-PR body, PR fetches,
29902
- // etc.) feel less frozen even when they're sub-second.
29903
- const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
29904
- const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
29905
- const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
30217
+ const hasStatusMessage = Boolean(state.statusMessage);
30218
+ const isLoading = Boolean(state.statusLoading && hasStatusMessage);
29906
30219
  const isError = state.statusKind === 'error';
30220
+ const isWarning = state.statusKind === 'warning';
29907
30221
  const isSuccess = state.statusKind === 'success';
29908
- const contextualText = isError
29909
- // Errors get the full footer width and a `✗` prefix so they read
29910
- // as alarming. We drop the contextual hints when an error is
29911
- // active they'd compete for attention with the message and
29912
- // long validator outputs (#907 polish: split-plan validator
29913
- // errors are often 100+ chars and got truncated against the hints).
29914
- ? `✗ ${state.statusMessage || ''}`
29915
- : `${hints.contextual.join(' ')}${status}`;
30222
+ // 'info' is the implicit kind when statusKind is undefined but
30223
+ // statusMessage is set it's a deliberate status update, not an
30224
+ // idle tip, so it gets info treatment rather than the dim fallback.
30225
+ const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
30226
+ const rawTrailing = state.statusMessage || idleTip || '';
30227
+ // Glyphs per kind so the message is identifiable even before reading
30228
+ // the color improves scan-ability and degrades gracefully when the
30229
+ // terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
30230
+ // = dumb / vt100) where unicode glyphs render as garbage.
30231
+ // loading → spinner (animated)
30232
+ // error → ✗ / !
30233
+ // warning → ⚠ / !
30234
+ // success → ✓ / +
30235
+ // info → ℹ / i
30236
+ // idle tip → no glyph (passive)
30237
+ const glyph = (() => {
30238
+ if (isLoading)
30239
+ return pickSpinnerFrame(spinnerFrame);
30240
+ if (isError)
30241
+ return theme.ascii ? '!' : '✗';
30242
+ if (isWarning)
30243
+ return theme.ascii ? '!' : '⚠';
30244
+ if (isSuccess)
30245
+ return theme.ascii ? '+' : '✓';
30246
+ if (isInfo)
30247
+ return theme.ascii ? 'i' : 'ℹ';
30248
+ return '';
30249
+ })();
30250
+ const statusBody = rawTrailing
30251
+ ? glyph
30252
+ ? `${glyph} ${rawTrailing}`
30253
+ : rawTrailing
30254
+ : '';
30255
+ // Row 2 color picks. Each kind gets its own theme color so success
30256
+ // and loading are visually distinct (was conflated under `accent`
30257
+ // pre-redesign — users couldn't tell "done" from "in progress").
30258
+ // loading → accent (cyan / preset blue)
30259
+ // error → danger (red / preset red)
30260
+ // warning → warning (yellow)
30261
+ // success → success (green)
30262
+ // info → info (blue / preset accent in light themes)
30263
+ // idle → undefined + dim (passive, blends with chrome)
30264
+ const statusColor = isError
30265
+ ? theme.colors.danger
30266
+ : isWarning
30267
+ ? theme.colors.warning
30268
+ : isSuccess
30269
+ ? theme.colors.success
30270
+ : isLoading
30271
+ ? theme.colors.accent
30272
+ : isInfo
30273
+ ? theme.colors.info
30274
+ : undefined;
30275
+ const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
30276
+ const statusDim = !statusBold;
30277
+ const hintsText = hints.contextual.join(' ');
29916
30278
  const globalText = hints.global.join(' · ');
29917
- // Error rendering: hide the global hints on the right so the
29918
- // message can wrap into that space. Success rendering: accent
29919
- // color on the message, hints stay visible. Default: existing
29920
- // muted styling.
29921
- const contextualColor = isError
29922
- ? 'red'
29923
- : isSuccess
29924
- ? theme.colors.accent
29925
- : theme.colors.muted;
29926
- return h(Box, {
29927
- flexDirection: 'row',
29928
- height: 2,
29929
- justifyContent: 'space-between',
29930
- paddingX: 1,
29931
- }, h(Text, {
29932
- color: contextualColor,
29933
- dimColor: !isError && !isSuccess,
29934
- bold: isError,
29935
- }, contextualText),
29936
- // Globals are dropped entirely when an error is on screen — that
29937
- // space is what the long message needs to render. They come back
29938
- // the moment the status flips to info / success / cleared.
29939
- isError
29940
- ? h(Text, undefined, '')
29941
- : h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
30279
+ return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
30280
+ // Row 1: contextual global hints. justifyContent pushes them
30281
+ // to opposite edges so the eye can scan each cluster as one
30282
+ // block instead of hunting through a single concatenated line.
30283
+ h(Box, { flexDirection: 'row', justifyContent: 'space-between' }, h(Text, { color: theme.colors.muted, dimColor: true }, hintsText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText)),
30284
+ // Row 2: status / loading / idle tip / error. Empty Text keeps
30285
+ // the row reserved when nothing's set so the surrounding layout
30286
+ // doesn't shift as status flips on/off.
30287
+ h(Text, {
30288
+ color: statusColor,
30289
+ dimColor: statusDim,
30290
+ bold: statusBold,
30291
+ }, statusBody));
30292
+ }
30293
+
30294
+ const COMBINING_MARK_RANGES = [
30295
+ [0x0300, 0x036f],
30296
+ [0x1ab0, 0x1aff],
30297
+ [0x1dc0, 0x1dff],
30298
+ [0x20d0, 0x20ff],
30299
+ [0xfe20, 0xfe2f],
30300
+ ];
30301
+ const WIDE_CHARACTER_RANGES = [
30302
+ [0x1100, 0x115f],
30303
+ [0x2329, 0x232a],
30304
+ [0x2e80, 0xa4cf],
30305
+ [0xac00, 0xd7a3],
30306
+ [0xf900, 0xfaff],
30307
+ [0xfe10, 0xfe19],
30308
+ [0xfe30, 0xfe6f],
30309
+ [0xff00, 0xff60],
30310
+ [0xffe0, 0xffe6],
30311
+ [0x2600, 0x27bf],
30312
+ [0x1f000, 0x1f9ff],
30313
+ [0x20000, 0x3fffd],
30314
+ ];
30315
+ function isInRange(codePoint, ranges) {
30316
+ return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30317
+ }
30318
+ function characterWidth(character) {
30319
+ const codePoint = character.codePointAt(0) || 0;
30320
+ if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30321
+ return 0;
30322
+ }
30323
+ if (codePoint === 0x200d ||
30324
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30325
+ isInRange(codePoint, COMBINING_MARK_RANGES)) {
30326
+ return 0;
30327
+ }
30328
+ return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30329
+ }
30330
+ function cellWidth(value) {
30331
+ return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30332
+ }
30333
+ /**
30334
+ * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30335
+ * on whitespace where possible; falls back to mid-word splits when a single
30336
+ * word is wider than the budget. Preserves blank input as a single empty
30337
+ * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30338
+ */
30339
+ function wrapCells(value, width) {
30340
+ if (width < 1) {
30341
+ return [value];
30342
+ }
30343
+ if (cellWidth(value) <= width) {
30344
+ return [value];
30345
+ }
30346
+ const lines = [];
30347
+ let current = '';
30348
+ let currentWidth = 0;
30349
+ const flush = () => {
30350
+ if (current.length > 0) {
30351
+ lines.push(current);
30352
+ current = '';
30353
+ currentWidth = 0;
30354
+ }
30355
+ };
30356
+ // Tokenize into runs of whitespace + non-whitespace so we can keep word
30357
+ // boundaries when possible.
30358
+ const tokens = value.match(/\s+|\S+/g) || [];
30359
+ for (const token of tokens) {
30360
+ const tokenWidth = cellWidth(token);
30361
+ if (currentWidth + tokenWidth <= width) {
30362
+ current += token;
30363
+ currentWidth += tokenWidth;
30364
+ continue;
30365
+ }
30366
+ if (/^\s+$/.test(token)) {
30367
+ // Drop boundary whitespace at line breaks.
30368
+ flush();
30369
+ continue;
30370
+ }
30371
+ flush();
30372
+ if (tokenWidth <= width) {
30373
+ current = token;
30374
+ currentWidth = tokenWidth;
30375
+ continue;
30376
+ }
30377
+ // Word longer than budget — hard-split into chunks.
30378
+ let remaining = token;
30379
+ while (cellWidth(remaining) > width) {
30380
+ let chunk = '';
30381
+ let chunkWidth = 0;
30382
+ for (const character of Array.from(remaining)) {
30383
+ const charW = characterWidth(character);
30384
+ if (chunkWidth + charW > width)
30385
+ break;
30386
+ chunk += character;
30387
+ chunkWidth += charW;
30388
+ }
30389
+ lines.push(chunk);
30390
+ remaining = remaining.slice(chunk.length);
30391
+ }
30392
+ if (remaining.length > 0) {
30393
+ current = remaining;
30394
+ currentWidth = cellWidth(remaining);
30395
+ }
30396
+ }
30397
+ flush();
30398
+ return lines.length > 0 ? lines : [value];
30399
+ }
30400
+ function truncateCells(value, width) {
30401
+ if (width < 1) {
30402
+ return '';
30403
+ }
30404
+ if (cellWidth(value) <= width) {
30405
+ return value;
30406
+ }
30407
+ const suffix = width > 3 ? '...' : '';
30408
+ const available = width - cellWidth(suffix);
30409
+ let used = 0;
30410
+ let output = '';
30411
+ for (const character of Array.from(value)) {
30412
+ const nextWidth = characterWidth(character);
30413
+ if (used + nextWidth > available) {
30414
+ break;
30415
+ }
30416
+ output += character;
30417
+ used += nextWidth;
30418
+ }
30419
+ return `${output}${suffix}`;
30420
+ }
30421
+ /**
30422
+ * Truncate a file path so the filename (last segment) is preserved,
30423
+ * eliding middle directory segments with `…/` instead of dropping
30424
+ * end-of-string characters.
30425
+ *
30426
+ * `truncateCells` is the wrong tool for paths because it preserves the
30427
+ * START of the string and drops the END — losing the filename, which
30428
+ * is the most useful part. Example with `truncateCells`:
30429
+ *
30430
+ * "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
30431
+ *
30432
+ * `truncatePathCells` preserves the filename and elides middle:
30433
+ *
30434
+ * "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
30435
+ *
30436
+ * The algorithm tries successively-smaller prefixes (keeping the start
30437
+ * of the path, the filename, and replacing the dropped middle segments
30438
+ * with `…`) and returns the largest variant that fits. When even
30439
+ * `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
30440
+ * the abbreviated form — better to show end-of-name than start-of-path.
30441
+ *
30442
+ * For inputs without `/` separators, behaves identically to
30443
+ * `truncateCells`. Empty / width-0 cases match `truncateCells` too.
30444
+ *
30445
+ * @example
30446
+ * truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
30447
+ * truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
30448
+ * truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
30449
+ * truncatePathCells('plainname.ts', 8) // 'plain...'
30450
+ */
30451
+ function truncatePathCells(value, width) {
30452
+ if (width < 1)
30453
+ return '';
30454
+ if (cellWidth(value) <= width)
30455
+ return value;
30456
+ // No path structure to exploit — fall through to plain truncation.
30457
+ if (!value.includes('/'))
30458
+ return truncateCells(value, width);
30459
+ const segments = value.split('/');
30460
+ const filename = segments[segments.length - 1] ?? '';
30461
+ const prefix = segments.slice(0, -1);
30462
+ // Path is just '/filename' or has only the filename — no middle to
30463
+ // elide. Defer to plain truncation.
30464
+ if (prefix.length === 0)
30465
+ return truncateCells(value, width);
30466
+ // Walk from "keep all prefix segments except the deepest" down to
30467
+ // "keep no prefix segments." First variant that fits wins.
30468
+ for (let keep = prefix.length - 1; keep >= 0; keep--) {
30469
+ const candidate = keep === 0
30470
+ ? `…/${filename}`
30471
+ : `${prefix.slice(0, keep).join('/')}/…/${filename}`;
30472
+ if (cellWidth(candidate) <= width)
30473
+ return candidate;
30474
+ }
30475
+ // Even `…/<filename>` doesn't fit. Use plain truncation on that
30476
+ // form — preserves the leading `…/` so the user knows a path was
30477
+ // elided, then ellipsis-truncates the filename.
30478
+ return truncateCells(`…/${filename}`, width);
29942
30479
  }
29943
30480
 
29944
30481
  /**
@@ -30165,218 +30702,318 @@ function sidebarTabCount(tab, context) {
30165
30702
  }
30166
30703
  }
30167
30704
 
30168
- const COMBINING_MARK_RANGES = [
30169
- [0x0300, 0x036f],
30170
- [0x1ab0, 0x1aff],
30171
- [0x1dc0, 0x1dff],
30172
- [0x20d0, 0x20ff],
30173
- [0xfe20, 0xfe2f],
30174
- ];
30175
- const WIDE_CHARACTER_RANGES = [
30176
- [0x1100, 0x115f],
30177
- [0x2329, 0x232a],
30178
- [0x2e80, 0xa4cf],
30179
- [0xac00, 0xd7a3],
30180
- [0xf900, 0xfaff],
30181
- [0xfe10, 0xfe19],
30182
- [0xfe30, 0xfe6f],
30183
- [0xff00, 0xff60],
30184
- [0xffe0, 0xffe6],
30185
- [0x2600, 0x27bf],
30186
- [0x1f000, 0x1f9ff],
30187
- [0x20000, 0x3fffd],
30188
- ];
30189
- function isInRange(codePoint, ranges) {
30190
- return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30191
- }
30192
- function characterWidth(character) {
30193
- const codePoint = character.codePointAt(0) || 0;
30194
- if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30195
- return 0;
30196
- }
30197
- if (codePoint === 0x200d ||
30198
- (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30199
- isInRange(codePoint, COMBINING_MARK_RANGES)) {
30200
- return 0;
30201
- }
30202
- return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30203
- }
30204
- function cellWidth(value) {
30205
- return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30206
- }
30207
30705
  /**
30208
- * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30209
- * on whitespace where possible; falls back to mid-word splits when a single
30210
- * word is wider than the budget. Preserves blank input as a single empty
30211
- * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30706
+ * Header chip builder. Turns the workstation's title-bar state into an
30707
+ * ordered list of small visually-distinct chips:
30708
+ *
30709
+ * coco · gfargo/coco · main · ✓ clean · ⊘ no PR · [NORMAL]
30710
+ *
30711
+ * Pre-refactor the title bar concatenated every segment into a single
30712
+ * Text span, which made the eye read the whole thing as one run of
30713
+ * words (the same problem the footer had). Splitting into chips with a
30714
+ * fixed separator lets each segment carry its own color and lets the
30715
+ * user scan the bar in chunks — "what app, what repo, what branch,
30716
+ * how clean, what PR state, what mode" — instead of parsing left-to-
30717
+ * right.
30718
+ *
30719
+ * Why a separate module: the header runtime renders chips and handles
30720
+ * truncation; chip construction is pure transformation of state +
30721
+ * context + theme. Splitting them keeps the chips testable in
30722
+ * isolation and keeps the runtime small.
30723
+ *
30724
+ * Truncation strategy lives in the consumer, not here — when the total
30725
+ * width exceeds the column budget, the header falls back to the
30726
+ * pre-redesign single-fragment truncated string so the ellipsis can't
30727
+ * land mid-glyph. We always return the FULL chip list; the consumer
30728
+ * decides whether to drop chips, fall back, or render all of them.
30212
30729
  */
30213
- function wrapCells(value, width) {
30214
- if (width < 1) {
30215
- return [value];
30730
+ /**
30731
+ * Default separator inserted between chips by the consumer. Exported as
30732
+ * a constant so tests and width math agree on what they're measuring.
30733
+ * The trailing/leading spaces are part of the separator — `·` alone
30734
+ * would butt against adjacent chip labels.
30735
+ */
30736
+ const HEADER_CHIP_SEPARATOR = ' · ';
30737
+ /**
30738
+ * Build the ordered chip list for the header. Chips not relevant to the
30739
+ * current state (no PR loaded, no breadcrumb, no search input, …) are
30740
+ * omitted entirely rather than rendered as empty placeholders, so the
30741
+ * consumer can just `chips.map(render)` without checking for empties.
30742
+ */
30743
+ function buildHeaderChips(input) {
30744
+ const { theme } = input;
30745
+ const chips = [];
30746
+ // App label — the constant identity. Accent + bold so it anchors the
30747
+ // left edge of the bar.
30748
+ chips.push({
30749
+ id: 'app',
30750
+ label: input.appLabel,
30751
+ color: theme.colors.accent,
30752
+ dim: false,
30753
+ bold: true,
30754
+ });
30755
+ // Repo. Default color — it's contextual but not the headline.
30756
+ chips.push({
30757
+ id: 'repo',
30758
+ label: input.repo,
30759
+ color: undefined,
30760
+ dim: false,
30761
+ bold: false,
30762
+ });
30763
+ // Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
30764
+ // is identifiable even when the branch name is generic ("main" /
30765
+ // "master").
30766
+ const branchGlyph = theme.ascii ? 'git:' : '⎇';
30767
+ chips.push({
30768
+ id: 'branch',
30769
+ label: `${branchGlyph} ${input.branch}`,
30770
+ color: theme.colors.accent,
30771
+ dim: false,
30772
+ bold: true,
30773
+ });
30774
+ // Dirty/clean. Positive framing on clean (success color + ✓), warning
30775
+ // on dirty (warning color + ●). ASCII fallbacks keep the chip
30776
+ // identifiable on dumb terminals.
30777
+ const dirtyChip = input.dirty
30778
+ ? {
30779
+ id: 'dirty',
30780
+ label: theme.ascii ? '* dirty' : '● dirty',
30781
+ color: theme.colors.warning,
30782
+ dim: false,
30783
+ bold: false,
30784
+ }
30785
+ : {
30786
+ id: 'dirty',
30787
+ label: theme.ascii ? '+ clean' : '✓ clean',
30788
+ color: theme.colors.success,
30789
+ dim: false,
30790
+ bold: false,
30791
+ };
30792
+ chips.push(dirtyChip);
30793
+ // Bisect — only when active. Distinct chip so users entering the TUI
30794
+ // mid-bisect see it immediately (#784). Warning color because bisect
30795
+ // is an "in progress, requires user action" state.
30796
+ if (input.bisecting) {
30797
+ chips.push({
30798
+ id: 'bisecting',
30799
+ label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
30800
+ color: theme.colors.warning,
30801
+ dim: false,
30802
+ bold: true,
30803
+ });
30216
30804
  }
30217
- if (cellWidth(value) <= width) {
30218
- return [value];
30805
+ // PR state. When present, the chip uses the PR-state glyph + a short
30806
+ // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
30807
+ // "no PR" chip so users know the system DID look (vs. the bar just
30808
+ // being blank).
30809
+ if (input.pullRequest) {
30810
+ const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
30811
+ const stateLabel = input.pullRequest.isDraft
30812
+ ? 'DRAFT'
30813
+ : input.pullRequest.state.toUpperCase();
30814
+ const label = prGlyph.glyph
30815
+ ? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
30816
+ : `PR #${input.pullRequest.number} ${stateLabel}`;
30817
+ chips.push({
30818
+ id: 'pr',
30819
+ label,
30820
+ color: prGlyph.color,
30821
+ dim: prGlyph.dim,
30822
+ bold: false,
30823
+ });
30219
30824
  }
30220
- const lines = [];
30221
- let current = '';
30222
- let currentWidth = 0;
30223
- const flush = () => {
30224
- if (current.length > 0) {
30225
- lines.push(current);
30226
- current = '';
30227
- currentWidth = 0;
30228
- }
30229
- };
30230
- // Tokenize into runs of whitespace + non-whitespace so we can keep word
30231
- // boundaries when possible.
30232
- const tokens = value.match(/\s+|\S+/g) || [];
30233
- for (const token of tokens) {
30234
- const tokenWidth = cellWidth(token);
30235
- if (currentWidth + tokenWidth <= width) {
30236
- current += token;
30237
- currentWidth += tokenWidth;
30238
- continue;
30239
- }
30240
- if (/^\s+$/.test(token)) {
30241
- // Drop boundary whitespace at line breaks.
30242
- flush();
30243
- continue;
30244
- }
30245
- flush();
30246
- if (tokenWidth <= width) {
30247
- current = token;
30248
- currentWidth = tokenWidth;
30249
- continue;
30250
- }
30251
- // Word longer than budget — hard-split into chunks.
30252
- let remaining = token;
30253
- while (cellWidth(remaining) > width) {
30254
- let chunk = '';
30255
- let chunkWidth = 0;
30256
- for (const character of Array.from(remaining)) {
30257
- const charW = characterWidth(character);
30258
- if (chunkWidth + charW > width)
30259
- break;
30260
- chunk += character;
30261
- chunkWidth += charW;
30262
- }
30263
- lines.push(chunk);
30264
- remaining = remaining.slice(chunk.length);
30265
- }
30266
- if (remaining.length > 0) {
30267
- current = remaining;
30268
- currentWidth = cellWidth(remaining);
30269
- }
30825
+ else {
30826
+ chips.push({
30827
+ id: 'pr',
30828
+ label: theme.ascii ? '- no PR' : '⊘ no PR',
30829
+ color: theme.colors.muted,
30830
+ dim: true,
30831
+ bold: false,
30832
+ });
30270
30833
  }
30271
- flush();
30272
- return lines.length > 0 ? lines : [value];
30273
- }
30274
- function truncateCells(value, width) {
30275
- if (width < 1) {
30276
- return '';
30834
+ // View breadcrumb. Rendered only when there's content (`coco ui`
30835
+ // root view no breadcrumb chip; pushed into a sub-view → chip
30836
+ // appears). Comes AFTER PR so the "state" group (app/repo/branch/
30837
+ // dirty/PR) reads as one cluster and the "navigation" group (view
30838
+ // breadcrumb / loading) reads as a separate cluster.
30839
+ if (input.breadcrumb) {
30840
+ chips.push({
30841
+ id: 'view',
30842
+ label: input.breadcrumb,
30843
+ color: theme.colors.muted,
30844
+ dim: true,
30845
+ bold: false,
30846
+ });
30277
30847
  }
30278
- if (cellWidth(value) <= width) {
30279
- return value;
30848
+ if (input.loading) {
30849
+ chips.push({
30850
+ id: 'loading',
30851
+ label: input.loading.trim(),
30852
+ color: theme.colors.muted,
30853
+ dim: true,
30854
+ bold: false,
30855
+ });
30280
30856
  }
30281
- const suffix = width > 3 ? '...' : '';
30282
- const available = width - cellWidth(suffix);
30283
- let used = 0;
30284
- let output = '';
30285
- for (const character of Array.from(value)) {
30286
- const nextWidth = characterWidth(character);
30287
- if (used + nextWidth > available) {
30288
- break;
30289
- }
30290
- output += character;
30291
- used += nextWidth;
30857
+ // Mode the explicit input-mode indicator (#P2.2). Always present
30858
+ // so users never wonder why `q` doesn't quit while they're editing.
30859
+ // EDIT / FILTER use the warning color to signal "your keystrokes
30860
+ // mean something different right now"; NORMAL uses accent (matches
30861
+ // the app chip's home base).
30862
+ const modeColor = input.mode === 'NORMAL'
30863
+ ? theme.colors.accent
30864
+ : theme.colors.warning;
30865
+ chips.push({
30866
+ id: 'mode',
30867
+ label: `[${input.mode}]`,
30868
+ color: modeColor,
30869
+ dim: false,
30870
+ bold: true,
30871
+ });
30872
+ // Search — only when active. Dim so it doesn't compete with the
30873
+ // identity chips for attention; the user knows it's there because
30874
+ // they're typing into it.
30875
+ if (input.search) {
30876
+ chips.push({
30877
+ id: 'search',
30878
+ label: input.search,
30879
+ color: theme.colors.muted,
30880
+ dim: true,
30881
+ bold: false,
30882
+ });
30292
30883
  }
30293
- return `${output}${suffix}`;
30884
+ return chips;
30885
+ }
30886
+ /**
30887
+ * Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
30888
+ * between every pair. Used by the consumer to decide whether the
30889
+ * chip layout fits the column budget or whether to fall back to the
30890
+ * single-fragment truncated path.
30891
+ */
30892
+ function measureHeaderChipsWidth(chips) {
30893
+ if (chips.length === 0)
30894
+ return 0;
30895
+ const labels = chips.map((chip) => cellWidth(chip.label));
30896
+ const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
30897
+ return labels.reduce((sum, w) => sum + w, 0) + separators;
30294
30898
  }
30295
30899
 
30296
30900
  /**
30297
- * Title-bar renderer. Surfaces:
30298
- * - the app label (e.g. "coco ui")
30299
- * - current repo owner/name (or "local repository")
30300
- * - current branch + dirty / BISECTING flag
30301
- * - PR glyph + label when one is detected
30302
- * - breadcrumb of the view stack
30303
- * - loading hint for boot / context fetches
30304
- * - mode indicator: [NORMAL] / [EDIT] / [FILTER]
30305
- * - active filter / search input
30901
+ * Title-bar renderer. Surfaces the workstation's identity + navigation
30902
+ * state as a row of small visually-distinct chips:
30306
30903
  *
30307
- * Truncation: when the assembled title overruns the available columns we
30308
- * fall back to a single-fragment Text (truncating the joined string) so
30309
- * the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
30310
- * glyph in its own colored span when there's headroom.
30904
+ * coco · gfargo/coco · main · clean · ⊘ no PR · [NORMAL]
30311
30905
  *
30312
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
30313
- * of #890. No behavior change.
30906
+ * Per-chip color/glyph treatment lets the user scan in chunks ("what
30907
+ * app, what repo, what branch, how clean, what PR state, what mode")
30908
+ * instead of parsing one long sentence. Chip construction is in
30909
+ * `chrome/headerChips.ts`; this runtime just renders.
30910
+ *
30911
+ * Truncation: when the assembled chip row overruns the available
30912
+ * columns we fall back to a single Text fragment (truncating the
30913
+ * joined chip labels) so the ellipsis can't land mid-glyph. This is
30914
+ * the same defensive pattern the pre-redesign single-fragment code
30915
+ * used, applied at the chip-list level instead of the inline glyph
30916
+ * split.
30917
+ *
30918
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30919
+ * 5a.7 of #890. Chip restructuring introduced post-0.54.2.
30314
30920
  */
30315
30921
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
30316
30922
  const { Box, Text } = components;
30923
+ // Pull the source state into the small "describe what to render"
30924
+ // shape the chip builder expects. Keeps the runtime decoupled from
30925
+ // the chip layout — the builder doesn't know about LogInkState /
30926
+ // LogInkContext, just plain values.
30317
30927
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
30318
- // #784 surface bisect-in-progress in the title bar so users entering
30319
- // the TUI mid-bisect see it immediately, before they navigate to gB.
30320
- const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
30321
- const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
30928
+ const dirty = Boolean(context.branches?.dirty);
30929
+ const bisecting = Boolean(context.bisect?.active);
30322
30930
  const repo = context.provider?.repository.owner && context.provider.repository.name
30323
30931
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
30324
30932
  : 'local repository';
30325
30933
  const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
30326
- const prGlyph = prInfo ? getPullRequestStateGlyph(prInfo, theme) : null;
30327
- const prLabel = prInfo
30328
- ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
30329
- : 'no PR';
30330
- const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
30331
- // Boot loading wins over the per-context loading hint because it
30332
- // tells the user the headline thing they care about (commits aren't
30333
- // ready yet) — the context fetches finish independently and surface
30334
- // their own per-section loading copy in the sidebars.
30934
+ // Boot loading wins over the per-context loading hint — same
30935
+ // priority as pre-redesign. Context fetches still surface their own
30936
+ // copy in the sidebars.
30335
30937
  const loading = state.bootLoading
30336
- ? ' loading commits'
30337
- : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
30938
+ ? 'loading commits'
30939
+ : isLogInkContextLoading(contextStatus) ? 'loading context' : '';
30338
30940
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
30339
30941
  const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
30340
- // Repo breadcrumb (when nested) comes first so the user sees which
30341
- // submodule they're in at a glance, then the view breadcrumb (when
30342
- // pushed deeper than the root view). The truncate fallback in the
30343
- // title row still applies — when both fight for space, the ellipsis
30344
- // lands at the end of whichever segment overflows.
30345
30942
  const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
30346
- // Mode indicator (P2.2) — surfaces the current input mode so users
30347
- // never wonder why `q` doesn't quit while they're editing or filtering.
30348
30943
  const mode = state.commitCompose.editing
30349
- ? '[EDIT]'
30944
+ ? 'EDIT'
30350
30945
  : state.filterMode
30351
- ? '[FILTER]'
30352
- : '[NORMAL]';
30353
- const titlePrefix = `${appLabel} ${repo} ${branch} ${dirty} `;
30354
- const glyphPart = prGlyph?.glyph ? `${prGlyph.glyph} ` : '';
30355
- const titleSuffix = `${view}${loading}`;
30356
- const fullTitle = `${titlePrefix}${glyphPart}${prLabel}${titleSuffix}`;
30357
- const titleBudget = columns - mode.length - 4;
30358
- const truncatedTitle = truncateCells(fullTitle, titleBudget);
30359
- // Only split into colored fragments when the prefix + glyph + label all
30360
- // fit unmodified — otherwise the truncate ellipsis can land mid-fragment
30361
- // and we'd render half a glyph in the wrong color.
30362
- const splitFragments = truncatedTitle === fullTitle && glyphPart.length > 0;
30363
- const modeColor = theme.noColor
30364
- ? undefined
30365
- : state.filterMode || state.commitCompose.editing
30366
- ? theme.colors.warning
30367
- : theme.colors.accent;
30946
+ ? 'FILTER'
30947
+ : 'NORMAL';
30948
+ const search = state.filterMode
30949
+ ? `search: ${state.filter}_`
30950
+ : state.filter
30951
+ ? `filter: ${state.filter}`
30952
+ : '';
30953
+ const chips = buildHeaderChips({
30954
+ appLabel,
30955
+ repo,
30956
+ branch,
30957
+ dirty,
30958
+ bisecting,
30959
+ pullRequest: prInfo ? {
30960
+ number: prInfo.number,
30961
+ state: prInfo.state,
30962
+ isDraft: prInfo.isDraft,
30963
+ } : undefined,
30964
+ breadcrumb: view,
30965
+ loading,
30966
+ mode,
30967
+ search: search ? truncateCells(search, 36) : '',
30968
+ theme,
30969
+ });
30970
+ // Truncation budget. Header line gets the full terminal width minus
30971
+ // the box's horizontal padding (2 cells) and a small safety margin.
30972
+ const budget = Math.max(0, columns - 4);
30973
+ const chipsWidth = measureHeaderChipsWidth(chips);
30368
30974
  return h(Box, {
30369
30975
  borderColor: theme.colors.border,
30370
30976
  borderStyle: theme.borderStyle,
30371
30977
  height: 3,
30372
30978
  paddingX: 1,
30373
- }, splitFragments
30374
- ? h(Text, { bold: true, color: theme.colors.accent }, titlePrefix)
30375
- : h(Text, { bold: true, color: theme.colors.accent }, truncatedTitle), splitFragments
30376
- ? h(Text, { bold: true, color: prGlyph?.color, dimColor: prGlyph?.dim }, glyphPart)
30377
- : undefined, splitFragments
30378
- ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
30379
- : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncateCells(search, 36)}`) : undefined);
30979
+ }, chipsWidth <= budget
30980
+ ? renderChipRow(h, Text, chips)
30981
+ : renderFallback(h, Text, chips, theme, budget));
30982
+ }
30983
+ /**
30984
+ * Render every chip as its own Text span with its own color/style,
30985
+ * interleaved with dim separator spans. This is the path used when
30986
+ * everything fits — the eye gets the full chip treatment.
30987
+ */
30988
+ function renderChipRow(h, Text, chips) {
30989
+ const nodes = [];
30990
+ chips.forEach((chip, index) => {
30991
+ if (index > 0) {
30992
+ // Separator is intentionally dim so the eye can use it as a
30993
+ // visual delimiter without it competing with chip labels for
30994
+ // attention.
30995
+ nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
30996
+ }
30997
+ nodes.push(h(Text, {
30998
+ key: chip.id,
30999
+ color: chip.color,
31000
+ dimColor: chip.dim,
31001
+ bold: chip.bold,
31002
+ }, chip.label));
31003
+ });
31004
+ return nodes;
31005
+ }
31006
+ /**
31007
+ * Fallback path for narrow terminals. Concatenates every chip label
31008
+ * with separators, then truncates the whole string with
31009
+ * `truncateCells` so the ellipsis lands at a cell boundary. Loses the
31010
+ * per-chip color treatment in exchange for guaranteed legibility on
31011
+ * narrow displays — the same trade-off the pre-redesign single-
31012
+ * fragment code made for its inline glyph color split.
31013
+ */
31014
+ function renderFallback(h, Text, chips, theme, budget) {
31015
+ const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
31016
+ return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
30380
31017
  }
30381
31018
 
30382
31019
  /**
@@ -30599,10 +31236,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
30599
31236
  const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
30600
31237
  const fileRows = worktree.files.slice(0, 12).map((file, index) => {
30601
31238
  const codes = `${file.indexStatus}${file.worktreeStatus}`;
31239
+ // Smart path truncation: keep the leading status codes and elide
31240
+ // middle directory segments to preserve the filename. Falls back
31241
+ // to plain truncation when the codes + a meaningful filename
31242
+ // don't both fit. Same shape as the detail surface so all the
31243
+ // status-row renderings elide consistently.
31244
+ const prefix = ` ${codes} `;
31245
+ const totalBudget = width - 4;
31246
+ const pathBudget = totalBudget - cellWidth(prefix);
31247
+ const label = pathBudget >= 8
31248
+ ? `${prefix}${truncatePathCells(file.path, pathBudget)}`
31249
+ : truncateCells(`${prefix}${file.path}`, totalBudget);
30602
31250
  return h(Text, {
30603
31251
  key: `tab-status-file-${index}`,
30604
31252
  color: colorOf(file.state),
30605
- }, truncateCells(` ${codes} ${file.path}`, width - 4));
31253
+ }, label);
30606
31254
  });
30607
31255
  return [
30608
31256
  summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
@@ -31945,7 +32593,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
31945
32593
  color: theme.noColor ? undefined : theme.colors.accent,
31946
32594
  backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
31947
32595
  inverse: isActive && focused,
31948
- }, truncateCells(`${arrow}${headerFile.path}`, width - 4));
32596
+ }, (() => {
32597
+ // Smart path truncation for the diff file header: keep
32598
+ // the leading arrow glyph and elide middle path
32599
+ // segments so the filename is never lost. Falls back to
32600
+ // plain truncation when there isn't room for a
32601
+ // meaningful filename.
32602
+ const pathBudget = (width - 4) - cellWidth(arrow);
32603
+ return pathBudget >= 8
32604
+ ? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
32605
+ : truncateCells(`${arrow}${headerFile.path}`, width - 4);
32606
+ })());
31949
32607
  }
31950
32608
  return h(Text, {
31951
32609
  key: `stash-diff-line-${absoluteIndex}`,
@@ -35426,6 +36084,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
35426
36084
  });
35427
36085
  return out;
35428
36086
  }
36087
+ /**
36088
+ * Compose a `<prefix><path><suffix>` line where the path gets smart
36089
+ * middle-elision truncation if needed, while the fixed prefix/suffix
36090
+ * decorations stay intact. Falls back to plain whole-line truncation
36091
+ * when the suffix decorations consume too much of the budget for the
36092
+ * path-aware variant to leave a meaningful filename.
36093
+ *
36094
+ * Used by the changed-files list AND the compose-context staged /
36095
+ * unstaged sections so all three places elide identically — same
36096
+ * floor (8 cells), same fallback shape.
36097
+ */
36098
+ function smartPathLabel(prefix, path, suffix, totalBudget) {
36099
+ const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
36100
+ if (pathBudget >= 8) {
36101
+ return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
36102
+ }
36103
+ return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
36104
+ }
35429
36105
  /**
35430
36106
  * Render a list of changed files with status-code colors and stats. Used
35431
36107
  * by both the history inspector and the commit-diff detail panel so the
@@ -35453,13 +36129,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
35453
36129
  // in `lfsPointer.ts` so even rename / mode-only rows are
35454
36130
  // flagged.
35455
36131
  const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
35456
- const label = `${cursor} ${statusCode} ${file.path}${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36132
+ // Smart path truncation via `smartPathLabel`: keeps the cursor +
36133
+ // status-code prefix and the stats/badge suffix intact, gives
36134
+ // the path's remaining width budget to middle-elision so the
36135
+ // filename survives instead of getting blunt-truncated off the
36136
+ // end (the issue users hit when inspector paths read like
36137
+ // `src/commands/log/da...`).
36138
+ const labelPrefix = `${cursor} ${statusCode} `;
36139
+ const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36140
+ const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
35457
36141
  return h(Text, {
35458
36142
  key: `commit-file-${index}`,
35459
36143
  color: statusCodeColor(file.status, theme),
35460
36144
  inverse: isSelected && focused && !theme.noColor,
35461
36145
  bold: isSelected,
35462
- }, truncateCells(label, width - 4));
36146
+ }, label);
35463
36147
  });
35464
36148
  }
35465
36149
  function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
@@ -35735,7 +36419,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35735
36419
  ...stagedFiles.map((file, index) => h(Text, {
35736
36420
  key: `compose-context-staged-${index}`,
35737
36421
  color: theme.noColor ? undefined : theme.colors.gitAdded,
35738
- }, truncateCells(` ${file.indexStatus} ${file.path}`, width - 4))),
36422
+ }, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
35739
36423
  h(Text, { key: 'compose-context-staged-spacer' }, ''),
35740
36424
  ]
35741
36425
  : []), ...(unstagedFiles.length
@@ -35744,7 +36428,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35744
36428
  ...unstagedFiles.map((file, index) => h(Text, {
35745
36429
  key: `compose-context-unstaged-${index}`,
35746
36430
  color: theme.noColor ? undefined : theme.colors.gitModified,
35747
- }, truncateCells(` ${file.worktreeStatus} ${file.path}`, width - 4))),
36431
+ }, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
35748
36432
  ]
35749
36433
  : !stagedFiles.length && !loadingWorktree
35750
36434
  ? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
@@ -36644,7 +37328,7 @@ function LogInkApp(deps) {
36644
37328
  if (cancelled || !mountedRef.current)
36645
37329
  return;
36646
37330
  const message = error instanceof Error ? error.message : String(error);
36647
- dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
37331
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
36648
37332
  dispatch({ type: 'setBootLoading', value: false });
36649
37333
  });
36650
37334
  return () => {
@@ -36712,8 +37396,15 @@ function LogInkApp(deps) {
36712
37396
  ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
36713
37397
  ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
36714
37398
  };
37399
+ // Stash commits as graph roots so post-operation refreshes
37400
+ // keep the same rich graph the boot loader assembled. Without
37401
+ // this, every commit / split-apply / etc. would drop stash
37402
+ // anchors and the cursor-syncs-history effect would degrade
37403
+ // back to "tip not in loaded window" for older stashes.
37404
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
36715
37405
  const fresh = await getLogRows(git, mergedArgv, {
36716
37406
  limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
37407
+ extraRefs: stashHashes,
36717
37408
  });
36718
37409
  if (mountedRef.current && fresh) {
36719
37410
  dispatch({ type: 'replaceRows', rows: fresh });
@@ -37279,12 +37970,33 @@ function LogInkApp(deps) {
37279
37970
  // fetched yet); a status hint surfaces in that case so the user
37280
37971
  // knows to toggle full graph or load older commits.
37281
37972
  const lastSyncedHashRef = React.useRef(undefined);
37973
+ // Tracks which target hashes we've already anchored a `git log`
37974
+ // fetch on (#1034 follow-up). When the cursor-syncs-history effect
37975
+ // sees a target whose hash isn't in the loaded window AND isn't in
37976
+ // this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
37977
+ // here. After the fetch resolves and rows are appended, the effect
37978
+ // re-fires; if the target STILL isn't loaded the resolver sees the
37979
+ // hash in this set and returns `unreachable` instead of looping.
37980
+ //
37981
+ // Stored as a ref because (a) the resolver only ever reads it and
37982
+ // (b) component re-renders on state.filteredCommits change are the
37983
+ // re-fire trigger; storing here in state would add a redundant
37984
+ // render per attempt.
37985
+ const attemptedContextHashesRef = React.useRef(new Set());
37986
+ const loadCommitContextRef = React.useRef(null);
37282
37987
  React.useEffect(() => {
37283
37988
  const onBranchTab = state.activeView === 'branches' ||
37284
37989
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37285
37990
  const onTagTab = state.activeView === 'tags' ||
37286
37991
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37287
- if (!onBranchTab && !onTagTab)
37992
+ // User-reported gap: cursoring a stash didn't sync the history
37993
+ // cursor the way cursoring a branch / tag did. Same auto-jump
37994
+ // affordance now extends to stashes; the stash's commit hash IS
37995
+ // the row to land on (stashes are commits living off the
37996
+ // `refs/stash` tree, visible under `--all` / fullGraph).
37997
+ const onStashTab = state.activeView === 'stash' ||
37998
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
37999
+ if (!onBranchTab && !onTagTab && !onStashTab)
37288
38000
  return;
37289
38001
  let targetHash;
37290
38002
  let targetLabel;
@@ -37310,51 +38022,117 @@ function LogInkApp(deps) {
37310
38022
  targetLabel = `tag ${tag.name}`;
37311
38023
  }
37312
38024
  }
37313
- if (!targetHash)
37314
- return;
37315
- // Skip the dispatch + status churn when the cursor hasn't
37316
- // actually changed which commit it's targeting (the case for
37317
- // rapid navigation through a cluster of branches that all point
37318
- // at the same commit). Without this guard the user sees a stream
37319
- // of "Synced history to <branch> tip" status messages even
37320
- // though the history cursor never moved.
37321
- if (targetHash === lastSyncedHashRef.current)
37322
- return;
37323
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
37324
- if (loaded) {
37325
- lastSyncedHashRef.current = targetHash;
37326
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
37327
- // Confirmation status message so the user gets feedback even
37328
- // when the dedicated branches / tags view is occupying the
37329
- // main panel and the history cursor moves invisibly behind it.
37330
- dispatch({
37331
- type: 'setStatus',
37332
- value: `Synced history to ${targetLabel} tip`,
37333
- });
38025
+ else if (onStashTab) {
38026
+ const all = context.stashes?.stashes || [];
38027
+ const visible = state.filter
38028
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
38029
+ : all;
38030
+ const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
38031
+ if (stash) {
38032
+ // Two-step fallback chain for stash cursor sync:
38033
+ //
38034
+ // 1. Try `baseHash` (the branch tip the stash was created
38035
+ // from). This answers the user-visible question "where
38036
+ // in larger git history was this stash made?" — that's
38037
+ // the branch origin point, not the stash's own merge-
38038
+ // commit row off in `refs/stash`. Base commits live on
38039
+ // regular branches so they're almost always in the
38040
+ // loaded window.
38041
+ //
38042
+ // 2. If `baseHash` isn't in the loaded window (the stash's
38043
+ // base branch was deleted, or the base is older than
38044
+ // the 1000-commit cap), fall back to `stash.hash`
38045
+ // itself. The stash commit was added as an extraRef so
38046
+ // it's reachable from the graph if it fits the window.
38047
+ //
38048
+ // Only after BOTH miss does the effect report "tip not in
38049
+ // loaded window." The label flips to mention "base" vs the
38050
+ // stash commit so the user knows what they're looking at.
38051
+ // hashesMatchAny handles the short-hash auto-extension
38052
+ // mismatch between `git stash list --format=%h` (stash hash)
38053
+ // and `git log --pretty=format:%h` (history row). Same
38054
+ // hazard as the branch/tag cursor sync — see src/git/hashes.ts.
38055
+ const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
38056
+ const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
38057
+ if (baseLoaded) {
38058
+ targetHash = stash.baseHash;
38059
+ targetLabel = `${stash.ref}'s base`;
38060
+ }
38061
+ else if (hashLoaded) {
38062
+ targetHash = stash.hash;
38063
+ targetLabel = stash.ref;
38064
+ }
38065
+ else {
38066
+ // Neither in window — set to baseHash so the standard
38067
+ // "not in loaded window" message fires with a meaningful
38068
+ // label (the base is what the user actually wants to see).
38069
+ targetHash = stash.baseHash || stash.hash;
38070
+ targetLabel = stash.ref;
38071
+ }
38072
+ }
37334
38073
  }
37335
- else {
37336
- dispatch({
37337
- type: 'setStatus',
37338
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
37339
- });
38074
+ // Delegate the actual decision to the pure resolver so the
38075
+ // logic is testable in isolation. The effect just performs the
38076
+ // resolver's chosen action.
38077
+ const decision = resolveCursorSyncDecision({
38078
+ target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
38079
+ loadedHashes: buildLoadedHashSet(state.filteredCommits),
38080
+ lastSyncedHash: lastSyncedHashRef.current,
38081
+ attemptedContextHashes: attemptedContextHashesRef.current,
38082
+ });
38083
+ switch (decision.type) {
38084
+ case 'noop':
38085
+ return;
38086
+ case 'jump':
38087
+ lastSyncedHashRef.current = decision.hash;
38088
+ dispatch({ type: 'selectCommitByHash', hash: decision.hash });
38089
+ dispatch({
38090
+ type: 'setStatus',
38091
+ value: `Synced history to ${decision.label} tip`,
38092
+ });
38093
+ return;
38094
+ case 'load-context':
38095
+ // Mark the hash as attempted BEFORE firing the load so a
38096
+ // re-fire of this effect (state.filteredCommits change while
38097
+ // the load is in flight) doesn't kick off a duplicate
38098
+ // request. The resolver sees the hash in the set and
38099
+ // returns `noop` until the load completes; on completion the
38100
+ // appendRows triggers a final re-fire that either jumps or
38101
+ // returns `unreachable`.
38102
+ attemptedContextHashesRef.current.add(decision.target.hash);
38103
+ void loadCommitContextRef.current?.(decision.target);
38104
+ return;
38105
+ case 'unreachable':
38106
+ dispatch({
38107
+ type: 'setStatus',
38108
+ value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
38109
+ kind: 'warning',
38110
+ });
38111
+ return;
37340
38112
  }
37341
38113
  }, [
37342
- dispatch, context.branches, context.tags,
38114
+ dispatch, context.branches, context.tags, context.stashes,
37343
38115
  state.activeView, state.focus, state.sidebarTab,
37344
- state.selectedBranchIndex, state.selectedTagIndex,
38116
+ state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
37345
38117
  state.branchSort, state.tagSort, state.filter,
37346
38118
  state.filteredCommits,
37347
38119
  ]);
37348
38120
  // Reset the dedup ref when the user moves focus away from the
37349
- // sidebar branches / tags tab so re-entering re-fires the sync
37350
- // even if the cursored branch is the same as before.
38121
+ // sidebar branches / tags / stashes tab so re-entering re-fires the
38122
+ // sync even if the cursored row is the same as before.
37351
38123
  React.useEffect(() => {
37352
38124
  const onBranchTab = state.activeView === 'branches' ||
37353
38125
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37354
38126
  const onTagTab = state.activeView === 'tags' ||
37355
38127
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37356
- if (!onBranchTab && !onTagTab) {
38128
+ const onStashTab = state.activeView === 'stash' ||
38129
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
38130
+ if (!onBranchTab && !onTagTab && !onStashTab) {
37357
38131
  lastSyncedHashRef.current = undefined;
38132
+ // Drop any context-load attempt tracking too. If the user
38133
+ // navigates back later we want to retry rather than show
38134
+ // "unreachable" based on a stale attempted-set.
38135
+ attemptedContextHashesRef.current = new Set();
37358
38136
  }
37359
38137
  }, [state.activeView, state.focus, state.sidebarTab]);
37360
38138
  React.useEffect(() => {
@@ -37385,7 +38163,7 @@ function LogInkApp(deps) {
37385
38163
  ]);
37386
38164
  const toggleSelectedFileStage = React.useCallback(async () => {
37387
38165
  if (!selectedWorktreeFile) {
37388
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38166
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37389
38167
  return;
37390
38168
  }
37391
38169
  dispatch({ type: 'setStatus', value: 'updating file stage state' });
@@ -37400,7 +38178,7 @@ function LogInkApp(deps) {
37400
38178
  const toggleSelectedHunkStage = React.useCallback(async () => {
37401
38179
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37402
38180
  if (!selectedHunk) {
37403
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38181
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37404
38182
  return;
37405
38183
  }
37406
38184
  dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
@@ -37414,6 +38192,7 @@ function LogInkApp(deps) {
37414
38192
  dispatch({
37415
38193
  type: 'setStatus',
37416
38194
  value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
38195
+ kind: 'success',
37417
38196
  });
37418
38197
  await refreshWorktreeContext();
37419
38198
  setWorktreeDiff(undefined);
@@ -37423,12 +38202,13 @@ function LogInkApp(deps) {
37423
38202
  dispatch({
37424
38203
  type: 'setStatus',
37425
38204
  value: error.message || 'failed to update hunk stage state',
38205
+ kind: 'error',
37426
38206
  });
37427
38207
  }
37428
38208
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37429
38209
  const revertSelectedFile = React.useCallback(async () => {
37430
38210
  if (!selectedWorktreeFile) {
37431
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38211
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37432
38212
  return;
37433
38213
  }
37434
38214
  dispatch({ type: 'setStatus', value: 'reverting selected file' });
@@ -37441,13 +38221,13 @@ function LogInkApp(deps) {
37441
38221
  const revertSelectedHunk = React.useCallback(async () => {
37442
38222
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37443
38223
  if (!selectedHunk) {
37444
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38224
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37445
38225
  return;
37446
38226
  }
37447
38227
  dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
37448
38228
  try {
37449
38229
  await revertHunk(git, selectedHunk);
37450
- dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}` });
38230
+ dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
37451
38231
  await refreshWorktreeContext();
37452
38232
  setWorktreeDiff(undefined);
37453
38233
  setWorktreeHunks(undefined);
@@ -37456,13 +38236,14 @@ function LogInkApp(deps) {
37456
38236
  dispatch({
37457
38237
  type: 'setStatus',
37458
38238
  value: error.message || 'failed to revert hunk',
38239
+ kind: 'error',
37459
38240
  });
37460
38241
  }
37461
38242
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37462
38243
  const createCommitFromCompose = React.useCallback(async () => {
37463
38244
  const stagedCount = context.worktree?.stagedCount || 0;
37464
38245
  if (!stagedCount) {
37465
- dispatch({ type: 'setStatus', value: 'stage changes before committing' });
38246
+ dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
37466
38247
  return;
37467
38248
  }
37468
38249
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
@@ -37554,12 +38335,12 @@ function LogInkApp(deps) {
37554
38335
  // dispatches because the user already knows what happened.
37555
38336
  if (result.cancelled) {
37556
38337
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37557
- dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
38338
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
37558
38339
  return;
37559
38340
  }
37560
38341
  if (result.ok && result.draft) {
37561
38342
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37562
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
38343
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
37563
38344
  return;
37564
38345
  }
37565
38346
  dispatch({
@@ -37643,7 +38424,7 @@ function LogInkApp(deps) {
37643
38424
  const startCreatePullRequest = React.useCallback(async () => {
37644
38425
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37645
38426
  if (!head) {
37646
- dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
38427
+ dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
37647
38428
  return;
37648
38429
  }
37649
38430
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37651,11 +38432,12 @@ function LogInkApp(deps) {
37651
38432
  dispatch({
37652
38433
  type: 'setStatus',
37653
38434
  value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
38435
+ kind: 'warning',
37654
38436
  });
37655
38437
  return;
37656
38438
  }
37657
38439
  if (head === defaultBranch) {
37658
- dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.` });
38440
+ dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
37659
38441
  return;
37660
38442
  }
37661
38443
  if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
@@ -37665,6 +38447,7 @@ function LogInkApp(deps) {
37665
38447
  value: existing
37666
38448
  ? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
37667
38449
  : `A pull request is already open for ${head}.`,
38450
+ kind: 'warning',
37668
38451
  });
37669
38452
  return;
37670
38453
  }
@@ -37705,10 +38488,10 @@ function LogInkApp(deps) {
37705
38488
  const initialBody = body.body || '';
37706
38489
  const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37707
38490
  if (!body.ok) {
37708
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
38491
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
37709
38492
  }
37710
38493
  else {
37711
- dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
38494
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
37712
38495
  }
37713
38496
  // Audit finding #11: clear the pending flag BEFORE opening the
37714
38497
  // prompt. If a future refactor adds an `await` between the flag
@@ -37775,17 +38558,18 @@ function LogInkApp(deps) {
37775
38558
  const yankText = React.useCallback(async (value, label) => {
37776
38559
  const clipboard = clipboardRunner || defaultClipboardRunner;
37777
38560
  if (!value) {
37778
- dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.` });
38561
+ dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
37779
38562
  return;
37780
38563
  }
37781
38564
  try {
37782
38565
  await clipboard(value);
37783
- dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.` });
38566
+ dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
37784
38567
  }
37785
38568
  catch (error) {
37786
38569
  dispatch({
37787
38570
  type: 'setStatus',
37788
38571
  value: `Copy failed (${label}): ${error.message}`,
38572
+ kind: 'error',
37789
38573
  });
37790
38574
  }
37791
38575
  }, [clipboardRunner, dispatch]);
@@ -37809,7 +38593,7 @@ function LogInkApp(deps) {
37809
38593
  const startChangelogView = React.useCallback(async (options = {}) => {
37810
38594
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37811
38595
  if (!head) {
37812
- dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
38596
+ dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
37813
38597
  return;
37814
38598
  }
37815
38599
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37861,7 +38645,7 @@ function LogInkApp(deps) {
37861
38645
  baseLabel,
37862
38646
  error: result.message,
37863
38647
  });
37864
- dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}` });
38648
+ dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
37865
38649
  return;
37866
38650
  }
37867
38651
  dispatch({
@@ -37876,6 +38660,7 @@ function LogInkApp(deps) {
37876
38660
  dispatch({
37877
38661
  type: 'setStatus',
37878
38662
  value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
38663
+ kind: 'success',
37879
38664
  });
37880
38665
  }, [
37881
38666
  context.branches?.currentBranch,
@@ -37896,7 +38681,7 @@ function LogInkApp(deps) {
37896
38681
  const yankChangelog = React.useCallback(() => {
37897
38682
  const text = state.changelogView.text;
37898
38683
  if (!text) {
37899
- dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
38684
+ dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
37900
38685
  return;
37901
38686
  }
37902
38687
  void yankText(text, 'changelog');
@@ -37909,7 +38694,7 @@ function LogInkApp(deps) {
37909
38694
  const openChangelogInEditor = React.useCallback(() => {
37910
38695
  const current = state.changelogView.text;
37911
38696
  if (current === undefined) {
37912
- dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
38697
+ dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
37913
38698
  return;
37914
38699
  }
37915
38700
  let dir;
@@ -37920,6 +38705,7 @@ function LogInkApp(deps) {
37920
38705
  dispatch({
37921
38706
  type: 'setStatus',
37922
38707
  value: `Failed to create temp file for editor: ${error.message}`,
38708
+ kind: 'error',
37923
38709
  });
37924
38710
  return;
37925
38711
  }
@@ -37931,6 +38717,7 @@ function LogInkApp(deps) {
37931
38717
  dispatch({
37932
38718
  type: 'setStatus',
37933
38719
  value: `Failed to seed temp file: ${error.message}`,
38720
+ kind: 'error',
37934
38721
  });
37935
38722
  try {
37936
38723
  fs$1.rmSync(dir, { recursive: true, force: true });
@@ -37954,13 +38741,13 @@ function LogInkApp(deps) {
37954
38741
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37955
38742
  const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
37956
38743
  if (result.error) {
37957
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38744
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37958
38745
  }
37959
38746
  else if (result.signal) {
37960
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38747
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37961
38748
  }
37962
38749
  else if (typeof result.status === 'number' && result.status !== 0) {
37963
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38750
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37964
38751
  }
37965
38752
  else {
37966
38753
  editorOk = true;
@@ -37975,12 +38762,13 @@ function LogInkApp(deps) {
37975
38762
  try {
37976
38763
  const content = fs$1.readFileSync(file, 'utf8');
37977
38764
  dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
37978
- dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
38765
+ dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
37979
38766
  }
37980
38767
  catch (error) {
37981
38768
  dispatch({
37982
38769
  type: 'setStatus',
37983
38770
  value: `Failed to read back edited changelog: ${error.message}`,
38771
+ kind: 'error',
37984
38772
  });
37985
38773
  }
37986
38774
  }
@@ -38021,19 +38809,19 @@ function LogInkApp(deps) {
38021
38809
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
38022
38810
  const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
38023
38811
  if (result.error) {
38024
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38812
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
38025
38813
  }
38026
38814
  else if (result.signal) {
38027
38815
  // Editor was killed by a signal (e.g. ^C, SIGTERM). status is
38028
38816
  // null in this case, so the old `status !== 0` check would
38029
38817
  // mistakenly fall through to the success branch.
38030
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38818
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
38031
38819
  }
38032
38820
  else if (typeof result.status === 'number' && result.status !== 0) {
38033
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38821
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
38034
38822
  }
38035
38823
  else {
38036
- dispatch({ type: 'setStatus', value: `Edited ${path}` });
38824
+ dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
38037
38825
  }
38038
38826
  }
38039
38827
  finally {
@@ -38080,6 +38868,7 @@ function LogInkApp(deps) {
38080
38868
  dispatch({
38081
38869
  type: 'setStatus',
38082
38870
  value: `Failed to create temp file for editor: ${error.message}`,
38871
+ kind: 'error',
38083
38872
  });
38084
38873
  return;
38085
38874
  }
@@ -38091,6 +38880,7 @@ function LogInkApp(deps) {
38091
38880
  dispatch({
38092
38881
  type: 'setStatus',
38093
38882
  value: `Failed to seed temp file: ${error.message}`,
38883
+ kind: 'error',
38094
38884
  });
38095
38885
  try {
38096
38886
  fs$1.rmSync(dir, { recursive: true, force: true });
@@ -38114,13 +38904,13 @@ function LogInkApp(deps) {
38114
38904
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
38115
38905
  const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
38116
38906
  if (result.error) {
38117
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38907
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
38118
38908
  }
38119
38909
  else if (result.signal) {
38120
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38910
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
38121
38911
  }
38122
38912
  else if (typeof result.status === 'number' && result.status !== 0) {
38123
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38913
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
38124
38914
  }
38125
38915
  else {
38126
38916
  editorOk = true;
@@ -38139,12 +38929,13 @@ function LogInkApp(deps) {
38139
38929
  try {
38140
38930
  const content = fs$1.readFileSync(file, 'utf8');
38141
38931
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
38142
- dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
38932
+ dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
38143
38933
  }
38144
38934
  catch (error) {
38145
38935
  dispatch({
38146
38936
  type: 'setStatus',
38147
38937
  value: `Failed to read back edited draft: ${error.message}`,
38938
+ kind: 'error',
38148
38939
  });
38149
38940
  }
38150
38941
  }
@@ -38220,7 +39011,7 @@ function LogInkApp(deps) {
38220
39011
  const applyCommitSplit = React.useCallback(async () => {
38221
39012
  const splitPlan = state.splitPlan;
38222
39013
  if (!splitPlan?.plan || !splitPlan.planContext) {
38223
- dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
39014
+ dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
38224
39015
  return;
38225
39016
  }
38226
39017
  // Diagnostic dump for the silent-failure bug surfaced in #944
@@ -39191,7 +39982,7 @@ function LogInkApp(deps) {
39191
39982
  };
39192
39983
  const handler = handlers[id];
39193
39984
  if (!handler) {
39194
- dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired` });
39985
+ dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
39195
39986
  return;
39196
39987
  }
39197
39988
  const result = await handler();
@@ -39236,7 +40027,37 @@ function LogInkApp(deps) {
39236
40027
  // without flickering the surfaces through a 'loading' phase.
39237
40028
  await refreshContext({ silent: true });
39238
40029
  }
39239
- }, [context, dispatch, git, refreshContext, refreshHistoryRows, state.branchSort, state.filter, state.selectedBranchIndex,
40030
+ // Stash workflow follow-up. Two distinct behaviours.
40031
+ //
40032
+ // **apply / pop**: the user brought stashed content back into the
40033
+ // worktree, but the sidebar still has them on the stash view.
40034
+ // Expected next move is "look at what landed in my worktree", so
40035
+ // jump them to history view (where the worktree counts in the
40036
+ // sidebar are visible) AND refresh worktree context explicitly so
40037
+ // the staged / unstaged / untracked numbers reflect the changes.
40038
+ //
40039
+ // **drop**: the silent context refresh above already re-fetched
40040
+ // the stash list, BUT users reported it feeling like nothing
40041
+ // happened. Fix two things: refresh worktree alongside (drops can
40042
+ // affect untracked files when the stash held `-u` state), and
40043
+ // surface the new stash count on the status line so there's
40044
+ // unambiguous feedback that the drop landed and the list shrank.
40045
+ if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
40046
+ dispatch({ type: 'pushView', value: 'history' });
40047
+ await refreshWorktreeContext();
40048
+ }
40049
+ if (result?.ok && id === 'drop-stash') {
40050
+ // Explicit worktree refresh in case the dropped stash carried
40051
+ // untracked-file state that's now collected.
40052
+ await refreshWorktreeContext();
40053
+ // The silent context refresh already replaced `context.stashes`;
40054
+ // reading the count back here would be stale because closures
40055
+ // capture the pre-refresh value. Status message stays generic
40056
+ // ("Dropped stash@{N}") — the visible list shrinking is the
40057
+ // unambiguous signal that the operation landed.
40058
+ }
40059
+ }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
40060
+ state.branchSort, state.filter, state.selectedBranchIndex,
39240
40061
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
39241
40062
  state.statusFilterMask, state.tagSort]);
39242
40063
  // Resolve the active view's "yank target" (commit hash / branch /
@@ -39398,15 +40219,15 @@ function LogInkApp(deps) {
39398
40219
  }
39399
40220
  }
39400
40221
  if (!value || !label) {
39401
- dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
40222
+ dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
39402
40223
  return;
39403
40224
  }
39404
40225
  try {
39405
40226
  await clipboard(value);
39406
- dispatch({ type: 'setStatus', value: `Copied ${label}` });
40227
+ dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
39407
40228
  }
39408
40229
  catch (error) {
39409
- dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}` });
40230
+ dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
39410
40231
  }
39411
40232
  }, [
39412
40233
  clipboardRunner,
@@ -39466,63 +40287,175 @@ function LogInkApp(deps) {
39466
40287
  React.useEffect(() => {
39467
40288
  loadingMoreCommitsRef.current = loadingMoreCommits;
39468
40289
  }, [loadingMoreCommits]);
40290
+ // STABLE useCallback (empty deps) for loadMoreCommits. The function
40291
+ // reads the volatile state (commit counts, fetch args, hasMore) via
40292
+ // refs that update on every render so the identity stays constant.
40293
+ //
40294
+ // Why stable matters: the cursor-syncs-history auto-load chain
40295
+ // calls this through a forward-reference ref (loadMoreCommitsRef).
40296
+ // If loadMoreCommits regenerated on every render — as the previous
40297
+ // implementation did via state deps — there was a render-order
40298
+ // race: the cursor sync effect would call the PREVIOUS render's
40299
+ // callback (still in the ref because the ref-setter useEffect runs
40300
+ // after the cursor-sync effect in declaration order), which had
40301
+ // captured a stale `state.commits.length` and re-fetched the same
40302
+ // window. The auto-load chain appeared to fire but never advanced
40303
+ // through history.
40304
+ //
40305
+ // Stable identity + refs sidesteps the race entirely: the function
40306
+ // never changes, and every call reads the latest state.
40307
+ const loadMoreStateRef = React.useRef({
40308
+ commitsLength: state.commits.length,
40309
+ filteredCommitsLength: state.filteredCommits.length,
40310
+ historyFetchArgs: state.historyFetchArgs,
40311
+ hasMoreCommits,
40312
+ logArgv,
40313
+ });
40314
+ loadMoreStateRef.current = {
40315
+ commitsLength: state.commits.length,
40316
+ filteredCommitsLength: state.filteredCommits.length,
40317
+ historyFetchArgs: state.historyFetchArgs,
40318
+ hasMoreCommits,
40319
+ logArgv,
40320
+ };
40321
+ const loadMoreCommits = React.useCallback(async (options = {}) => {
40322
+ const snap = loadMoreStateRef.current;
40323
+ if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
40324
+ return { fired: false, addedCommits: 0 };
40325
+ }
40326
+ if (snap.filteredCommitsLength === 0) {
40327
+ return { fired: false, addedCommits: 0 };
40328
+ }
40329
+ loadingMoreCommitsRef.current = true;
40330
+ const requestId = loadMoreRequestRef.current + 1;
40331
+ loadMoreRequestRef.current = requestId;
40332
+ setLoadingMoreCommits(true);
40333
+ dispatch({
40334
+ type: 'setStatus',
40335
+ value: options.statusMessage || 'loading older commits',
40336
+ loading: true,
40337
+ });
40338
+ const fetchArgs = snap.historyFetchArgs;
40339
+ const mergedArgv = {
40340
+ ...snap.logArgv,
40341
+ ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
40342
+ ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
40343
+ };
40344
+ // Load-more paths a fresh page from git AFTER what's already
40345
+ // loaded; pass the stash hashes again so the additional rows
40346
+ // stay graph-consistent with the boot fetch (a window that
40347
+ // dropped stashes mid-stream would render with broken junctions).
40348
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40349
+ const nextRows = await safe(getLogRows(git, mergedArgv, {
40350
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40351
+ skip: snap.commitsLength,
40352
+ extraRefs: stashHashes,
40353
+ }));
40354
+ if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
40355
+ return { fired: false, addedCommits: 0 };
40356
+ }
40357
+ loadingMoreCommitsRef.current = false;
40358
+ setLoadingMoreCommits(false);
40359
+ const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
40360
+ if (!nextRows) {
40361
+ dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
40362
+ return { fired: false, addedCommits: 0 };
40363
+ }
40364
+ if (nextRows?.length) {
40365
+ dispatch({ type: 'appendRows', rows: nextRows });
40366
+ }
40367
+ setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40368
+ return { fired: true, addedCommits: nextCommitCount };
40369
+ // Empty deps — the function is intentionally stable. State is
40370
+ // read via `loadMoreStateRef.current` at call time, and `dispatch`
40371
+ // / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
40372
+ // already stable across renders by React's contract.
40373
+ }, [dispatch, git]);
40374
+ // Scroll-near-bottom auto-trigger. Fires when the user's cursor is
40375
+ // within 20 rows of the last loaded commit so older history is
40376
+ // already on its way by the time they reach the bottom.
39469
40377
  React.useEffect(() => {
39470
40378
  const remaining = state.filteredCommits.length - state.selectedIndex - 1;
39471
- async function loadMoreCommits() {
39472
- if (!logArgv || logArgv.limit || loadingMoreCommitsRef.current || !hasMoreCommits) {
39473
- return;
39474
- }
39475
- if (state.filteredCommits.length === 0 || remaining > 20) {
39476
- return;
39477
- }
39478
- loadingMoreCommitsRef.current = true;
39479
- const requestId = loadMoreRequestRef.current + 1;
39480
- loadMoreRequestRef.current = requestId;
39481
- setLoadingMoreCommits(true);
39482
- dispatch({ type: 'setStatus', value: 'loading older commits' });
39483
- const fetchArgs = state.historyFetchArgs;
39484
- const mergedArgv = {
39485
- ...logArgv,
39486
- ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
39487
- ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
39488
- };
39489
- const nextRows = await safe(getLogRows(git, mergedArgv, {
39490
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
39491
- skip: state.commits.length,
39492
- }));
39493
- if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
39494
- return;
39495
- }
39496
- loadingMoreCommitsRef.current = false;
39497
- setLoadingMoreCommits(false);
39498
- const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
39499
- if (!nextRows) {
39500
- dispatch({ type: 'setStatus', value: 'failed to load older commits' });
39501
- return;
39502
- }
39503
- if (nextRows?.length) {
39504
- dispatch({ type: 'appendRows', rows: nextRows });
40379
+ if (remaining > 20)
40380
+ return;
40381
+ void loadMoreCommits().then((result) => {
40382
+ if (result.fired) {
40383
+ dispatch({
40384
+ type: 'setStatus',
40385
+ value: result.addedCommits
40386
+ ? `loaded ${result.addedCommits} older commits`
40387
+ : 'end of history',
40388
+ });
39505
40389
  }
39506
- setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
39507
- dispatch({
39508
- type: 'setStatus',
39509
- value: nextCommitCount
39510
- ? `loaded ${nextCommitCount} older commits`
39511
- : 'end of history',
39512
- });
39513
- }
39514
- void loadMoreCommits();
40390
+ });
39515
40391
  }, [
39516
40392
  dispatch,
39517
- git,
39518
- hasMoreCommits,
39519
- loadingMoreCommits,
39520
- logArgv,
39521
- state.commits.length,
40393
+ loadMoreCommits,
39522
40394
  state.filteredCommits.length,
39523
- state.historyFetchArgs,
39524
40395
  state.selectedIndex,
39525
40396
  ]);
40397
+ /**
40398
+ * Targeted-context loader for the cursor-syncs-history effect. Called
40399
+ * when the resolver returns `load-context` — the user cursored a
40400
+ * branch / tag / stash whose target commit isn't in the loaded
40401
+ * window, so we run a `git log` anchored on that commit (guaranteed
40402
+ * to include it) and merge the result via `appendRows` (which
40403
+ * already deduplicates by hash).
40404
+ *
40405
+ * Stable identity (empty deps) for the same reason as
40406
+ * `loadMoreCommits` — the cursor-sync effect calls this through a
40407
+ * forward-reference ref, and a regenerating callback would
40408
+ * reintroduce the render-order race that bit the previous chain.
40409
+ * All volatile state (logArgv, mostly) is read via refs.
40410
+ */
40411
+ const loadCommitContextStateRef = React.useRef({ logArgv });
40412
+ loadCommitContextStateRef.current = { logArgv };
40413
+ const loadCommitContext = React.useCallback(async (target) => {
40414
+ const snap = loadCommitContextStateRef.current;
40415
+ if (!snap.logArgv)
40416
+ return;
40417
+ dispatch({
40418
+ type: 'setStatus',
40419
+ value: `Loading commits around ${target.label}…`,
40420
+ loading: true,
40421
+ });
40422
+ try {
40423
+ // No stashHashes here — `getLogRowsAnchoredOn` walks only from
40424
+ // the target so it can guarantee the target's inclusion.
40425
+ // Stashes are already in the loaded graph from boot's
40426
+ // `loadRowsWithStashes`; `appendRows` deduplicates by hash so
40427
+ // the merged result keeps both views without double-counting.
40428
+ const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
40429
+ if (!mountedRef.current)
40430
+ return;
40431
+ if (rows.length > 0) {
40432
+ dispatch({ type: 'appendRows', rows });
40433
+ // Don't dispatch a setStatus here — the cursor-sync effect
40434
+ // will re-fire on the appendRows-driven filteredCommits
40435
+ // change and either jump (success) or report unreachable
40436
+ // (failure), surfacing the right message.
40437
+ }
40438
+ else {
40439
+ dispatch({
40440
+ type: 'setStatus',
40441
+ value: `${target.label} target commit returned no rows — orphan ref?`,
40442
+ kind: 'warning',
40443
+ });
40444
+ }
40445
+ }
40446
+ catch (error) {
40447
+ if (mountedRef.current) {
40448
+ dispatch({
40449
+ type: 'setStatus',
40450
+ value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
40451
+ kind: 'error',
40452
+ });
40453
+ }
40454
+ }
40455
+ }, [dispatch, git]);
40456
+ React.useEffect(() => {
40457
+ loadCommitContextRef.current = loadCommitContext;
40458
+ }, [loadCommitContext]);
39526
40459
  // Server-side history filter (#776). When the user submits `path:foo`
39527
40460
  // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
39528
40461
  // this effect picks up the change, re-runs `getLogRows` with merged
@@ -39558,12 +40491,16 @@ function LogInkApp(deps) {
39558
40491
  value: description ? `Refetching with ${description}` : 'Restoring full log',
39559
40492
  });
39560
40493
  void (async () => {
39561
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40494
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40495
+ const nextRows = await safe(getLogRows(git, merged, {
40496
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40497
+ extraRefs: stashHashes,
40498
+ }));
39562
40499
  if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
39563
40500
  return;
39564
40501
  }
39565
40502
  if (!nextRows) {
39566
- dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
40503
+ dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
39567
40504
  return;
39568
40505
  }
39569
40506
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39574,6 +40511,7 @@ function LogInkApp(deps) {
39574
40511
  value: description
39575
40512
  ? `Showing ${matched} commits matching ${description}`
39576
40513
  : 'Showing full log',
40514
+ kind: 'success',
39577
40515
  });
39578
40516
  })();
39579
40517
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
@@ -39603,12 +40541,20 @@ function LogInkApp(deps) {
39603
40541
  : 'Loading compact history…',
39604
40542
  });
39605
40543
  void (async () => {
39606
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40544
+ // Include stash commits as graph roots so the toggle's re-fetch
40545
+ // sees the same rich graph the boot loader assembles. Without
40546
+ // this, flipping `\` into full mode and back loses the stash
40547
+ // anchors that loadRowsWithStashes seeded on boot.
40548
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40549
+ const nextRows = await safe(getLogRows(git, merged, {
40550
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40551
+ extraRefs: stashHashes,
40552
+ }));
39607
40553
  if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
39608
40554
  return;
39609
40555
  }
39610
40556
  if (!nextRows) {
39611
- dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
40557
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
39612
40558
  return;
39613
40559
  }
39614
40560
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39619,6 +40565,7 @@ function LogInkApp(deps) {
39619
40565
  value: state.fullGraph
39620
40566
  ? `Showing ${matched} commits across all branches`
39621
40567
  : `Showing ${matched} commits (compact)`,
40568
+ kind: 'success',
39622
40569
  });
39623
40570
  })();
39624
40571
  }, [dispatch, git, logArgv, state.fullGraph]);
@@ -40313,6 +41260,17 @@ function createLogArgvFromUiArgv(argv) {
40313
41260
  return {
40314
41261
  $0: argv.$0,
40315
41262
  _: ['log'],
41263
+ // Pass `--all` through from the CLI. The yargs default is `true`
41264
+ // since 0.54.x — user feedback consistently asked for the
41265
+ // GitKraken-style "see all branches, tags, stashes" view as the
41266
+ // starting state. `coco ui --no-all` opts back to
41267
+ // current-branch-only.
41268
+ //
41269
+ // Note: passing `--branch foo` does NOT automatically scope away
41270
+ // from --all. If the user wants strictly that branch, they pass
41271
+ // `coco ui --branch foo --no-all`. We considered the implicit
41272
+ // scope-narrowing but it surprises users who pass `--branch` as
41273
+ // a "highlight this branch in the all-refs view" hint.
40316
41274
  all: argv.all,
40317
41275
  branch: argv.branch,
40318
41276
  format: 'table',
@@ -40349,6 +41307,26 @@ function withCacheWrite(repoPath, loader) {
40349
41307
  return rows;
40350
41308
  };
40351
41309
  }
41310
+ /**
41311
+ * Workstation-aware log loader (#1034 follow-up). Calls `git stash
41312
+ * list` first to collect every stash's commit hash, then passes them
41313
+ * as extra refs to `getLogRows` so the graph includes every stash as
41314
+ * a node — not just the latest (which is the only one `refs/stash`
41315
+ * points at and the only one `git log --all` walks).
41316
+ *
41317
+ * Without this, the stash → history cursor sync added in #1034 only
41318
+ * worked for `stash@{0}`; cursoring any older stash row reported
41319
+ * "tip not in loaded window" because that stash's commit hash was
41320
+ * never in the loaded graph window in the first place.
41321
+ *
41322
+ * The extra git call is cheap (one `git stash list --format=%H`,
41323
+ * usually sub-50ms). It's only an additive cost when stashes exist;
41324
+ * users on stash-free repos pay nothing.
41325
+ */
41326
+ async function loadRowsWithStashes(git, logArgv) {
41327
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
41328
+ return getLogRows(git, logArgv, { extraRefs: stashHashes });
41329
+ }
40352
41330
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
40353
41331
  const config = options.config || loadConfig(logArgv);
40354
41332
  const git = options.git || getRepo();
@@ -40367,7 +41345,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
40367
41345
  const initialRows = options.rows || cachedRows || [];
40368
41346
  const loadRows = options.rows
40369
41347
  ? undefined
40370
- : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
41348
+ : withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
40371
41349
  await startInkInteractiveLog(git, initialRows, {}, {
40372
41350
  appLabel: 'coco',
40373
41351
  idleTips: config.logTui?.idleTips,
@@ -40395,7 +41373,7 @@ async function startCocoUi(argv) {
40395
41373
  idleTips: config.logTui?.idleTips,
40396
41374
  dateBucketing: config.logTui?.dateBucketing,
40397
41375
  initialView: argv.view || 'history',
40398
- loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
41376
+ loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
40399
41377
  logArgv,
40400
41378
  theme: createUiTheme(config, argv),
40401
41379
  });
@@ -41598,9 +42576,9 @@ const options = {
41598
42576
  default: 'history',
41599
42577
  },
41600
42578
  all: {
41601
- description: 'Load commits from all local and remote refs in history mode',
42579
+ description: 'Load commits from all local and remote refs in history mode. Defaults to true so the history view shows the full multi-ref graph (branches, tags, stashes) out of the box; pass `--no-all` to scope to the current branch only.',
41602
42580
  type: 'boolean',
41603
- default: false,
42581
+ default: true,
41604
42582
  },
41605
42583
  branch: {
41606
42584
  description: 'Load history reachable from a branch or ref',