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
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.54.1";
64
+ const BUILD_VERSION = "0.55.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -18351,7 +18351,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
18351
18351
  const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
18352
18352
  const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
18353
18353
  const LOG_DEFAULT_LIMIT = 30;
18354
- const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
18354
+ // Bumped from 300 → 1000 in 0.54.2. With the full-graph default
18355
+ // (#1034) the workstation surfaces many more refs (all branches, all
18356
+ // tags, plus stash commits added via `extraRefs`), and on active repos
18357
+ // the 300-commit cap was cutting off year+-old stash bases and old
18358
+ // tag commits — making the cursor-syncs-history effect report "tip
18359
+ // not in loaded window" instead of moving the graph cursor. 1000
18360
+ // fits a year of activity for most repos, git log is still sub-200ms,
18361
+ // and Ink virtualises scroll so render cost stays flat.
18362
+ const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
18355
18363
  function toArray(value) {
18356
18364
  if (!value) {
18357
18365
  return [];
@@ -18531,12 +18539,77 @@ function buildLogArgs(argv, options = {}) {
18531
18539
  else if (argv.branch) {
18532
18540
  args.push(argv.branch);
18533
18541
  }
18542
+ // Extra refs (stash commits etc.) — append after the --all / branch
18543
+ // selector but BEFORE the path separator. Git treats them as
18544
+ // additional graph roots, so the traversal includes them alongside
18545
+ // whatever --all / --branch already covers.
18546
+ if (options.extraRefs && options.extraRefs.length > 0) {
18547
+ args.push(...options.extraRefs);
18548
+ }
18534
18549
  const paths = toArray(argv.path);
18535
18550
  if (paths.length > 0) {
18536
18551
  args.push('--', ...paths);
18537
18552
  }
18538
18553
  return args;
18539
18554
  }
18555
+ /**
18556
+ * Default size of a targeted-context window. Sized to comfortably
18557
+ * cover a year of activity on most repos so the cursor-sync's
18558
+ * "jump to commit anchored on a ref I just selected" can succeed
18559
+ * without paginating through the whole history.
18560
+ */
18561
+ const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
18562
+ /**
18563
+ * Load a window of commits anchored on a specific hash. Used by the
18564
+ * cursor-sync effect when the user selects a ref (branch / tag /
18565
+ * stash) whose target commit isn't in the loaded graph window.
18566
+ *
18567
+ * Critical detail: this walks **only from the target** (and its
18568
+ * ancestors), NOT from `--all`. Why: when you combine `--all` with
18569
+ * `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
18570
+ * the result by date, and slices the newest N rows. If the target
18571
+ * is older than the Nth newest commit across all refs (very common
18572
+ * for year-old tags / branches on active repos), it falls off the
18573
+ * slice even though it was passed as a root. Walking from the
18574
+ * target alone guarantees the target IS the first row of the
18575
+ * output and its ancestors fill the rest.
18576
+ *
18577
+ * The caller merges the result via the `appendRows` reducer action
18578
+ * which deduplicates by hash, so the target's ancestry slots into
18579
+ * the existing `--all` graph cleanly. The user's loaded view ends
18580
+ * up as the union of: the original `--all` window + target's
18581
+ * ancestry — exactly what's needed for the cursor to land.
18582
+ *
18583
+ * Capped at `options.limit` (default 5000) to keep one targeted
18584
+ * fetch bounded. For most refs, even a 100-commit limit would be
18585
+ * enough to surface the target; we go higher to also pull in the
18586
+ * surrounding context so the user can scroll around the landed
18587
+ * cursor.
18588
+ */
18589
+ async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
18590
+ // Strip every "walk many refs" toggle so buildLogArgs produces a
18591
+ // clean `git log <flags> <targetHash>` — exactly the walk that
18592
+ // guarantees the target's inclusion.
18593
+ const merged = {
18594
+ ...argv,
18595
+ all: false,
18596
+ view: 'compact', // suppresses 'full' → '--all' mapping
18597
+ branch: undefined,
18598
+ path: undefined,
18599
+ };
18600
+ // Also drop --first-parent / --no-merges so the target's ancestry
18601
+ // renders with full topology (matters for stash commits which are
18602
+ // merges by construction).
18603
+ const baseArgs = buildLogArgs(merged, {
18604
+ limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
18605
+ }).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
18606
+ // Splice the target as the positional ref. `buildLogArgs` already
18607
+ // appended any `--all`/`--branch`/`<extraRefs>` it considered;
18608
+ // since we cleared all those above, the only positional ref we
18609
+ // add is the target.
18610
+ baseArgs.push(targetHash);
18611
+ return parseLogOutput(await git.raw(baseArgs));
18612
+ }
18540
18613
  /**
18541
18614
  * Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
18542
18615
  *
@@ -18644,6 +18717,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18644
18717
  };
18645
18718
  }
18646
18719
 
18720
+ function parseStashSubject(subject) {
18721
+ const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18722
+ if (!match) {
18723
+ return {
18724
+ branch: '<unknown>',
18725
+ message: subject,
18726
+ };
18727
+ }
18728
+ return {
18729
+ branch: match[1],
18730
+ message: match[2] || subject,
18731
+ };
18732
+ }
18733
+ function parseStashList(output) {
18734
+ return output
18735
+ .split('\n')
18736
+ .map((line) => line.trim())
18737
+ .filter(Boolean)
18738
+ .map((line) => {
18739
+ const [ref, hash, parents, date, subject] = line.split('\x1f');
18740
+ const parsedSubject = parseStashSubject(subject || '');
18741
+ // `%P` returns space-separated parent hashes. Stash commits are
18742
+ // merges with 2-3 parents; the FIRST is the base (HEAD at stash
18743
+ // time). Empty parents string (legacy / corrupted entries) maps
18744
+ // to an empty baseHash; the cursor-sync caller treats that as
18745
+ // "no base available, fall back to stash hash."
18746
+ const baseHash = parents ? (parents.split(' ')[0] || '') : '';
18747
+ return {
18748
+ ref,
18749
+ hash,
18750
+ baseHash,
18751
+ date,
18752
+ branch: parsedSubject.branch,
18753
+ message: parsedSubject.message,
18754
+ };
18755
+ });
18756
+ }
18757
+ function parseStashFiles(output) {
18758
+ return output
18759
+ .split('\n')
18760
+ .map((line) => line.trim())
18761
+ .filter(Boolean);
18762
+ }
18763
+ /**
18764
+ * Resolve the commit hashes for every stash, in `stash@{N}` order.
18765
+ *
18766
+ * Used by the workstation's history loader to include older stashes
18767
+ * as graph roots — `git log --all` only walks `refs/stash` (the
18768
+ * latest stash) by default, so stash@{1+} commits live off-graph
18769
+ * unless explicitly referenced. Passing this list as positional refs
18770
+ * to `git log` makes every stash appear as a graph node, which lets
18771
+ * the cursor-syncs-history effect actually land on them when the
18772
+ * user navigates the stashes sidebar.
18773
+ *
18774
+ * Cheap: one `git stash list` call, no per-stash fan-out. Returns
18775
+ * an empty array when there are no stashes — callers can pass the
18776
+ * result through unconditionally.
18777
+ */
18778
+ async function getStashCommitHashes(git) {
18779
+ const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
18780
+ return raw
18781
+ .split('\n')
18782
+ .map((line) => line.trim())
18783
+ .filter(Boolean);
18784
+ }
18785
+ async function getStashOverview(git) {
18786
+ // Format fields (separated by 0x1f / unit separator):
18787
+ // %gd — stash reflog selector (stash@{N})
18788
+ // %H — stash commit hash
18789
+ // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18790
+ // %ci — committer date, ISO format
18791
+ // %gs — reflog subject ("WIP on main: <subject>")
18792
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
18793
+ return {
18794
+ stashes: await Promise.all(stashes.map(async (stash) => ({
18795
+ ...stash,
18796
+ files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18797
+ }))),
18798
+ };
18799
+ }
18800
+ /**
18801
+ * Full unified-patch diff for a stash. Used by the diff surface when
18802
+ * `state.diffSource === 'stash'` to render the stash's changes inline.
18803
+ *
18804
+ * Empty stashes (e.g. created by `git stash --keep-index` against an
18805
+ * already-clean tree) return [] rather than throwing — surfaces fall
18806
+ * back to a "no diff to display" message.
18807
+ */
18808
+ async function getStashDiff(git, stashRef) {
18809
+ return (await git.raw(['stash', 'show', '-p', stashRef]))
18810
+ .split('\n')
18811
+ .map((line) => line.replace(/\r$/, ''));
18812
+ }
18813
+ /**
18814
+ * Slice a unified-patch into per-file sections. Each entry records the
18815
+ * file path and the offset of its `diff --git` header within `lines`.
18816
+ * Used by the stash explorer to build a per-file cursor + cherry-pick
18817
+ * the file at the cursor.
18818
+ *
18819
+ * Renames / moves return the destination path (the `b/` side); the
18820
+ * action surface treats that as the path to materialize from the stash.
18821
+ *
18822
+ * Path quoting: git wraps paths containing spaces or special characters
18823
+ * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18824
+ * The parser handles both the unquoted and quoted forms; without that,
18825
+ * stash-file navigation and cherry-pick silently broke for any file
18826
+ * whose path contained a space.
18827
+ */
18828
+ function parseStashDiffFiles(lines) {
18829
+ const files = [];
18830
+ for (let i = 0; i < lines.length; i += 1) {
18831
+ const line = lines[i];
18832
+ const parsed = parseDiffGitHeader(line);
18833
+ if (parsed) {
18834
+ files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18835
+ }
18836
+ }
18837
+ return files;
18838
+ }
18839
+ /**
18840
+ * Resolve which stash file *contains* a given line offset — the user's
18841
+ * cursor scrolls through a concatenated multi-file patch, and this is
18842
+ * what powers the "File N/M: <path>" panel header, the inline header
18843
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18844
+ * dispatchers' "what file is the cursor on" lookup.
18845
+ *
18846
+ * Returns `undefined` when the file list is empty *or* the offset
18847
+ * lands before the very first file's `diff --git` header (e.g. when
18848
+ * `--stat` summary lines lead the patch). Callers fall through to a
18849
+ * "no file selected" state in that case.
18850
+ */
18851
+ function findStashFileForOffset(files, offset) {
18852
+ if (files.length === 0)
18853
+ return undefined;
18854
+ let current;
18855
+ for (const file of files) {
18856
+ if (file.startLine <= offset) {
18857
+ current = file;
18858
+ }
18859
+ else {
18860
+ break;
18861
+ }
18862
+ }
18863
+ // First file is the canonical fallback — even if the offset lands
18864
+ // before its header (rare), we want the cursor to be "in" something
18865
+ // so the user's actions have a target.
18866
+ return current ?? files[0];
18867
+ }
18868
+ const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18869
+ function parseDiffGitHeader(line) {
18870
+ const match = line.match(DIFF_GIT_HEADER);
18871
+ if (!match)
18872
+ return undefined;
18873
+ const aPath = unescapeGitQuoted(match[1]) || match[2];
18874
+ const bPath = unescapeGitQuoted(match[3]) || match[4];
18875
+ if (!aPath || !bPath)
18876
+ return undefined;
18877
+ return { aPath, bPath };
18878
+ }
18879
+ function unescapeGitQuoted(value) {
18880
+ if (value === undefined)
18881
+ return undefined;
18882
+ // Git's diff header quoting escapes `"`, `\`, and the usual
18883
+ // C-style sequences. Reverse the most common ones so callers get the
18884
+ // raw on-disk path.
18885
+ return value
18886
+ .replace(/\\\\/g, '\\')
18887
+ .replace(/\\"/g, '"')
18888
+ .replace(/\\t/g, '\t')
18889
+ .replace(/\\n/g, '\n');
18890
+ }
18891
+
18647
18892
  const FIELD_SEPARATOR$2 = '\x1f';
18648
18893
  function parseBranchRefs(output) {
18649
18894
  return output
@@ -18836,143 +19081,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18836
19081
  }
18837
19082
  }
18838
19083
 
18839
- function parseStashSubject(subject) {
18840
- const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18841
- if (!match) {
18842
- return {
18843
- branch: '<unknown>',
18844
- message: subject,
18845
- };
18846
- }
18847
- return {
18848
- branch: match[1],
18849
- message: match[2] || subject,
18850
- };
18851
- }
18852
- function parseStashList(output) {
18853
- return output
18854
- .split('\n')
18855
- .map((line) => line.trim())
18856
- .filter(Boolean)
18857
- .map((line) => {
18858
- const [ref, hash, date, subject] = line.split('\x1f');
18859
- const parsedSubject = parseStashSubject(subject || '');
18860
- return {
18861
- ref,
18862
- hash,
18863
- date,
18864
- branch: parsedSubject.branch,
18865
- message: parsedSubject.message,
18866
- };
18867
- });
18868
- }
18869
- function parseStashFiles(output) {
18870
- return output
18871
- .split('\n')
18872
- .map((line) => line.trim())
18873
- .filter(Boolean);
18874
- }
18875
- async function getStashOverview(git) {
18876
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
18877
- return {
18878
- stashes: await Promise.all(stashes.map(async (stash) => ({
18879
- ...stash,
18880
- files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18881
- }))),
18882
- };
18883
- }
18884
- /**
18885
- * Full unified-patch diff for a stash. Used by the diff surface when
18886
- * `state.diffSource === 'stash'` to render the stash's changes inline.
18887
- *
18888
- * Empty stashes (e.g. created by `git stash --keep-index` against an
18889
- * already-clean tree) return [] rather than throwing — surfaces fall
18890
- * back to a "no diff to display" message.
18891
- */
18892
- async function getStashDiff(git, stashRef) {
18893
- return (await git.raw(['stash', 'show', '-p', stashRef]))
18894
- .split('\n')
18895
- .map((line) => line.replace(/\r$/, ''));
18896
- }
18897
- /**
18898
- * Slice a unified-patch into per-file sections. Each entry records the
18899
- * file path and the offset of its `diff --git` header within `lines`.
18900
- * Used by the stash explorer to build a per-file cursor + cherry-pick
18901
- * the file at the cursor.
18902
- *
18903
- * Renames / moves return the destination path (the `b/` side); the
18904
- * action surface treats that as the path to materialize from the stash.
18905
- *
18906
- * Path quoting: git wraps paths containing spaces or special characters
18907
- * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18908
- * The parser handles both the unquoted and quoted forms; without that,
18909
- * stash-file navigation and cherry-pick silently broke for any file
18910
- * whose path contained a space.
18911
- */
18912
- function parseStashDiffFiles(lines) {
18913
- const files = [];
18914
- for (let i = 0; i < lines.length; i += 1) {
18915
- const line = lines[i];
18916
- const parsed = parseDiffGitHeader(line);
18917
- if (parsed) {
18918
- files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18919
- }
18920
- }
18921
- return files;
18922
- }
18923
- /**
18924
- * Resolve which stash file *contains* a given line offset — the user's
18925
- * cursor scrolls through a concatenated multi-file patch, and this is
18926
- * what powers the "File N/M: <path>" panel header, the inline header
18927
- * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18928
- * dispatchers' "what file is the cursor on" lookup.
18929
- *
18930
- * Returns `undefined` when the file list is empty *or* the offset
18931
- * lands before the very first file's `diff --git` header (e.g. when
18932
- * `--stat` summary lines lead the patch). Callers fall through to a
18933
- * "no file selected" state in that case.
18934
- */
18935
- function findStashFileForOffset(files, offset) {
18936
- if (files.length === 0)
18937
- return undefined;
18938
- let current;
18939
- for (const file of files) {
18940
- if (file.startLine <= offset) {
18941
- current = file;
18942
- }
18943
- else {
18944
- break;
18945
- }
18946
- }
18947
- // First file is the canonical fallback — even if the offset lands
18948
- // before its header (rare), we want the cursor to be "in" something
18949
- // so the user's actions have a target.
18950
- return current ?? files[0];
18951
- }
18952
- const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18953
- function parseDiffGitHeader(line) {
18954
- const match = line.match(DIFF_GIT_HEADER);
18955
- if (!match)
18956
- return undefined;
18957
- const aPath = unescapeGitQuoted(match[1]) || match[2];
18958
- const bPath = unescapeGitQuoted(match[3]) || match[4];
18959
- if (!aPath || !bPath)
18960
- return undefined;
18961
- return { aPath, bPath };
18962
- }
18963
- function unescapeGitQuoted(value) {
18964
- if (value === undefined)
18965
- return undefined;
18966
- // Git's diff header quoting escapes `"`, `\`, and the usual
18967
- // C-style sequences. Reverse the most common ones so callers get the
18968
- // raw on-disk path.
18969
- return value
18970
- .replace(/\\\\/g, '\\')
18971
- .replace(/\\"/g, '"')
18972
- .replace(/\\t/g, '\t')
18973
- .replace(/\\n/g, '\n');
18974
- }
18975
-
18976
19084
  function fileState(indexStatus, worktreeStatus) {
18977
19085
  if (indexStatus === '?' && worktreeStatus === '?') {
18978
19086
  return 'untracked';
@@ -19039,7 +19147,18 @@ function parseTagRefs(output) {
19039
19147
  .map((line) => line.trimEnd())
19040
19148
  .filter(Boolean)
19041
19149
  .map((line) => {
19042
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
19150
+ const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
19151
+ // For annotated tags `%(objectname:short)` returns the TAG
19152
+ // OBJECT's SHA, not the commit it points to — that's the SHA
19153
+ // sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
19154
+ // dereferences the tag and yields the commit's SHA, but is
19155
+ // EMPTY for lightweight tags (which are already direct
19156
+ // pointers to commits). Prefer the dereferenced form when
19157
+ // present, fall back to the object SHA otherwise. This is what
19158
+ // lets cursor-sync find the tagged commit in the loaded log
19159
+ // window — anchoring on the tag object's own SHA would never
19160
+ // match a commit row.
19161
+ const hash = derefedHash || objectHash;
19043
19162
  return {
19044
19163
  name,
19045
19164
  hash,
@@ -19051,7 +19170,7 @@ function parseTagRefs(output) {
19051
19170
  async function getTagOverview(git) {
19052
19171
  const output = await git.raw([
19053
19172
  'for-each-ref',
19054
- `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19173
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19055
19174
  '--sort=-creatordate',
19056
19175
  'refs/tags',
19057
19176
  ]);
@@ -19933,6 +20052,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
19933
20052
  output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
19934
20053
  }
19935
20054
 
20055
+ /**
20056
+ * Shared hash-matching helpers for cross-command lookups.
20057
+ *
20058
+ * Git surfaces the same commit with different short-hash lengths
20059
+ * depending on which command produced the row:
20060
+ *
20061
+ * - `for-each-ref --format=%(objectname:short)` (branches, tags,
20062
+ * stashes) honors `core.abbrev`, typically 7 chars.
20063
+ * - `git log --pretty=format:%h` (history rows) honors the same
20064
+ * setting BUT git auto-extends abbreviations to keep them unique
20065
+ * within the walked set — so the same commit can come back as 7
20066
+ * chars from one command and 8 (or more) from another.
20067
+ *
20068
+ * Consequence: any exact-equality lookup that compares a hash from
20069
+ * `for-each-ref` against a hash from `git log` will miss the match
20070
+ * even when both refer to the same commit. This bit the workstation's
20071
+ * cursor-sync effect twice during 0.54.2 — once in the resolver, once
20072
+ * in the `selectCommitByHash` reducer — and shows up wherever a ref
20073
+ * hash is checked against the loaded log window.
20074
+ *
20075
+ * The fix is bidirectional prefix matching: a hash matches another if
20076
+ * one is a prefix of the other. Below a 4-char floor we refuse to
20077
+ * match — three chars would collide with too many real commits.
20078
+ *
20079
+ * This module is the canonical place for that logic. Import it
20080
+ * anywhere you compare a "hash from one git formatter" against a
20081
+ * "hash from a different git formatter."
20082
+ *
20083
+ * Lives in `src/git/` because both `workstation/` and `commands/log/`
20084
+ * depend on it — `commands/log/` must not depend on `workstation/`,
20085
+ * so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
20086
+ */
20087
+ /**
20088
+ * Minimum length below which we refuse to prefix-match. Three chars
20089
+ * is too small to be a meaningful unique prefix for any real-world
20090
+ * git history.
20091
+ */
20092
+ const MIN_PREFIX_LENGTH = 4;
20093
+ /**
20094
+ * True when `a` and `b` refer to the same commit, tolerating
20095
+ * short-hash length differences from different git formatters.
20096
+ *
20097
+ * Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
20098
+ * string equality wins immediately (the common path); otherwise we
20099
+ * test bidirectional `startsWith` and bail when either input is too
20100
+ * short to be a meaningful prefix.
20101
+ */
20102
+ function hashesMatch(a, b) {
20103
+ if (!a || !b)
20104
+ return false;
20105
+ if (a === b)
20106
+ return true;
20107
+ if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
20108
+ return false;
20109
+ return a.startsWith(b) || b.startsWith(a);
20110
+ }
20111
+ /**
20112
+ * True when `hash` matches any entry in `candidates`. Convenience
20113
+ * wrapper for the common "is this ref's hash in any of the row's
20114
+ * hash variants?" check.
20115
+ */
20116
+ function hashesMatchAny(hash, candidates) {
20117
+ if (!hash)
20118
+ return false;
20119
+ return candidates.some((candidate) => hashesMatch(hash, candidate));
20120
+ }
20121
+ /**
20122
+ * True when `hash` is present in the loaded set — exact match first
20123
+ * (the O(1) fast path), then bidirectional `startsWith` over the set
20124
+ * to cover the formatter mismatch.
20125
+ *
20126
+ * The set is small in practice (1k–5k entries) so O(N) iteration on
20127
+ * miss is fine.
20128
+ */
20129
+ function hashLoaded(hash, loaded) {
20130
+ if (loaded.has(hash))
20131
+ return true;
20132
+ if (hash.length < MIN_PREFIX_LENGTH)
20133
+ return false;
20134
+ for (const entry of loaded) {
20135
+ if (entry.startsWith(hash) || hash.startsWith(entry))
20136
+ return true;
20137
+ }
20138
+ return false;
20139
+ }
20140
+
19936
20141
  const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
19937
20142
  /**
19938
20143
  * Parse a single `.gitattributes` body into the LFS-tracked
@@ -23625,7 +23830,16 @@ function createLogInkState(rows, options = {}) {
23625
23830
  worktreeDiffOffset: 0,
23626
23831
  filter: '',
23627
23832
  filterMode: false,
23628
- fullGraph: false,
23833
+ // Default to the full multi-ref graph (`git log --all`) so users
23834
+ // see how branches, tags, and stashes weave through the history
23835
+ // out of the box. Pre-0.54.x this defaulted to false (current
23836
+ // branch only); user feedback consistently asked for the
23837
+ // GitKraken-style "see everything" view as the starting state.
23838
+ // The `\` toggle still flips back to compact / current-branch
23839
+ // mode for users who want the cleaner single-line graph. Tests
23840
+ // override via `options.fullGraph` when they need the compact
23841
+ // case explicitly.
23842
+ fullGraph: options.fullGraph ?? true,
23629
23843
  showHelp: false,
23630
23844
  helpScrollOffset: 0,
23631
23845
  showCommandPalette: false,
@@ -23734,8 +23948,17 @@ function applyLogInkAction(state, action) {
23734
23948
  // branch's tip without the user manually scrolling. No-op when
23735
23949
  // the hash isn't in the loaded list (the runtime surfaces a
23736
23950
  // status hint in that case).
23951
+ //
23952
+ // Uses the shared `hashesMatchAny` helper to cover the
23953
+ // short-hash auto-extension mismatch between
23954
+ // `for-each-ref --format=%(objectname:short)` (cursored ref)
23955
+ // and `git log --pretty=format:%h` (history row). Without that
23956
+ // tolerance the resolver could decide "jump" but this reducer
23957
+ // would silently no-op — the status updates but the cursor
23958
+ // doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
23959
+ // testing. See `src/git/hashes.ts` for the matching rules.
23737
23960
  const target = action.hash;
23738
- const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
23961
+ const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
23739
23962
  if (index < 0) {
23740
23963
  return state;
23741
23964
  }
@@ -24672,7 +24895,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24672
24895
  const commit = state.filteredCommits[state.selectedIndex];
24673
24896
  const requireCommit = (fn) => {
24674
24897
  if (!commit) {
24675
- return [action({ type: 'setStatus', value: 'No commit selected' })];
24898
+ return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
24676
24899
  }
24677
24900
  return fn(commit.hash, state.selectedIndex);
24678
24901
  };
@@ -24711,6 +24934,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24711
24934
  return [action({
24712
24935
  type: 'setStatus',
24713
24936
  value: `Action ${inspectorAction.key} not yet wired`,
24937
+ kind: 'warning',
24714
24938
  })];
24715
24939
  }
24716
24940
  }
@@ -24925,6 +25149,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24925
25149
  return [action({
24926
25150
  type: 'setStatus',
24927
25151
  value: 'open the diff view and press [ or ] to jump hunks',
25152
+ kind: 'warning',
24928
25153
  })];
24929
25154
  case 'focusNext':
24930
25155
  return [action({ type: 'focusNext' })];
@@ -24973,6 +25198,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24973
25198
  return [action({
24974
25199
  type: 'setStatus',
24975
25200
  value: 'open branches / tags / history and press m on the cursored ref',
25201
+ kind: 'warning',
24976
25202
  })];
24977
25203
  case 'navigateBack':
24978
25204
  // Mirror the Esc / `<` semantics (#931): drain the frame's view
@@ -25048,6 +25274,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
25048
25274
  return [action({
25049
25275
  type: 'setStatus',
25050
25276
  value: 'Sort cycle is available in the branches and tags views',
25277
+ kind: 'warning',
25051
25278
  })];
25052
25279
  case 'yankClipboard':
25053
25280
  // The runtime resolves the value/label against the live filtered
@@ -25104,7 +25331,7 @@ function submitInputPrompt(state) {
25104
25331
  return [];
25105
25332
  const value = state.inputPrompt.value.trim();
25106
25333
  if (!value) {
25107
- return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
25334
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
25108
25335
  }
25109
25336
  if (state.inputPrompt.kind === 'reset-mode') {
25110
25337
  const mode = value.toLowerCase();
@@ -25112,6 +25339,7 @@ function submitInputPrompt(state) {
25112
25339
  return [action({
25113
25340
  type: 'setStatus',
25114
25341
  value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
25342
+ kind: 'warning',
25115
25343
  })];
25116
25344
  }
25117
25345
  return [
@@ -25125,6 +25353,7 @@ function submitInputPrompt(state) {
25125
25353
  return [action({
25126
25354
  type: 'setStatus',
25127
25355
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25356
+ kind: 'warning',
25128
25357
  })];
25129
25358
  }
25130
25359
  return [
@@ -25188,6 +25417,7 @@ function submitInputPrompt(state) {
25188
25417
  return [action({
25189
25418
  type: 'setStatus',
25190
25419
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25420
+ kind: 'warning',
25191
25421
  })];
25192
25422
  }
25193
25423
  return [
@@ -25785,7 +26015,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25785
26015
  }
25786
26016
  return [
25787
26017
  action({ type: 'setPendingKey', value: undefined }),
25788
- action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
26018
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
25789
26019
  ];
25790
26020
  }
25791
26021
  // `gT` chord: create a lightweight tag at the cursored commit on the
@@ -25809,7 +26039,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25809
26039
  }
25810
26040
  return [
25811
26041
  action({ type: 'setPendingKey', value: undefined }),
25812
- action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
26042
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
25813
26043
  ];
25814
26044
  }
25815
26045
  // #784 — bisect view action keys. Scoped to `state.activeView ===
@@ -25924,6 +26154,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25924
26154
  value: next === 'split'
25925
26155
  ? 'Switched to side-by-side diff'
25926
26156
  : 'Switched to unified diff',
26157
+ kind: 'success',
25927
26158
  }),
25928
26159
  ];
25929
26160
  }
@@ -26374,10 +26605,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26374
26605
  if (key.return && state.compareBase && isCompareFlowTarget(state)) {
26375
26606
  const head = getCursoredCompareRef(state, context);
26376
26607
  if (!head) {
26377
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26608
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26378
26609
  }
26379
26610
  if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
26380
- return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
26611
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
26381
26612
  }
26382
26613
  return [
26383
26614
  action({
@@ -26580,7 +26811,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26580
26811
  action({ type: 'setFocus', value: 'commits' }),
26581
26812
  ];
26582
26813
  }
26583
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
26814
+ return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
26584
26815
  }
26585
26816
  // Fall through — per-entity Enter handler below claims the keystroke.
26586
26817
  }
@@ -26691,7 +26922,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26691
26922
  if (inputValue === 'm' && isCompareFlowTarget(state)) {
26692
26923
  const ref = getCursoredCompareRef(state, context);
26693
26924
  if (!ref) {
26694
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26925
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26695
26926
  }
26696
26927
  if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
26697
26928
  return [
@@ -26922,7 +27153,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26922
27153
  // Always intercept `C` on the conflicts view to prevent fallthrough to
26923
27154
  // the global `C` (Create PR) binding when conflicts remain.
26924
27155
  if (inputValue === 'C' && state.activeView === 'conflicts') {
26925
- return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
27156
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
26926
27157
  }
26927
27158
  // Global `C` — create a pull request from the current branch. The
26928
27159
  // runtime callback handles pre-flight (current branch resolution,
@@ -26938,6 +27169,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26938
27169
  return [action({
26939
27170
  type: 'setStatus',
26940
27171
  value: 'Finish or cancel the commit draft before creating a PR.',
27172
+ kind: 'warning',
26941
27173
  })];
26942
27174
  }
26943
27175
  if (inputValue === 'C' && state.activeView !== 'conflicts') {
@@ -26997,7 +27229,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26997
27229
  return events;
26998
27230
  }
26999
27231
  if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
27000
- return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
27232
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
27001
27233
  }
27002
27234
  }
27003
27235
  // `c` on the history view cherry-picks the full selected commit on
@@ -27882,7 +28114,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
27882
28114
  rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
27883
28115
  tight: { min: 22, max: 28, fraction: 0.24 },
27884
28116
  normal: { min: 22, max: 30, fraction: 0.22 },
27885
- wide: { min: 28, max: 48, fraction: 0.24 },
28117
+ wide: { min: 28, max: 32, fraction: 0.20 },
27886
28118
  };
27887
28119
  function calcSidebarAtRestWidth(columns, density) {
27888
28120
  const config = SIDEBAR_AT_REST_BY_TIER[density];
@@ -29835,18 +30067,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
29835
30067
  ];
29836
30068
  }
29837
30069
 
30070
+ function resolveCursorSyncDecision(input) {
30071
+ if (!input.target) {
30072
+ return { type: 'noop', reason: 'no-target' };
30073
+ }
30074
+ if (input.target.hash === input.lastSyncedHash) {
30075
+ return { type: 'noop', reason: 'duplicate-of-last' };
30076
+ }
30077
+ if (isHashLoaded(input.target.hash, input.loadedHashes)) {
30078
+ return {
30079
+ type: 'jump',
30080
+ hash: input.target.hash,
30081
+ label: input.target.label,
30082
+ };
30083
+ }
30084
+ if (input.attemptedContextHashes.has(input.target.hash)) {
30085
+ return { type: 'unreachable', target: input.target };
30086
+ }
30087
+ return { type: 'load-context', target: input.target };
30088
+ }
30089
+ /**
30090
+ * Re-export of the shared `hashLoaded` helper under the resolver's
30091
+ * historical name. Kept exported so existing tests (and any external
30092
+ * importers) keep working unchanged — see `src/git/hashes.ts` for the
30093
+ * canonical implementation and the rationale behind bidirectional
30094
+ * prefix matching.
30095
+ */
30096
+ function isHashLoaded(hash, loadedHashes) {
30097
+ return hashLoaded(hash, loadedHashes);
30098
+ }
30099
+ /**
30100
+ * Build the membership set the resolver expects. Includes BOTH the
30101
+ * full hash and the short hash for every commit so the caller can
30102
+ * match either form (refs sometimes carry only the short hash and
30103
+ * `state.filteredCommits` items always have both).
30104
+ *
30105
+ * Exported so the cursor-sync effect can build the set once per
30106
+ * re-render and pass it down without leaking the implementation
30107
+ * detail. Tests use it to construct realistic inputs without
30108
+ * hand-rolling the dual-hash logic.
30109
+ */
30110
+ function buildLoadedHashSet(commits) {
30111
+ const set = new Set();
30112
+ for (const commit of commits) {
30113
+ if (commit.hash)
30114
+ set.add(commit.hash);
30115
+ if (commit.shortHash)
30116
+ set.add(commit.shortHash);
30117
+ }
30118
+ return set;
30119
+ }
30120
+
29838
30121
  /**
29839
- * Status-bar / footer renderer. Two-column layout:
29840
- * - Left: contextual hints for the active view (built by inkKeymap's
29841
- * `getLogInkFooterHints`), with the optional status message / idle
29842
- * tip appended.
29843
- * - Right: global key hints (`?` help, `:` palette, `q` quit, …).
30122
+ * Status-bar / footer renderer. Two-row layout, using the full
30123
+ * `height: 2` the footer already reserves:
30124
+ *
30125
+ * Row 1 — keyboard hint band:
30126
+ * ┌──── contextual hints ────┐ ┌──── globals ────┐
30127
+ * ↑/↓ branches ←/→ tab … ? help · : cmds · q
30128
+ *
30129
+ * Row 2 — status / feedback band:
30130
+ * ⠋ main has no upstream — nothing to fetch.
30131
+ *
30132
+ * Row 2 is empty when there's no status message, idle tip, or error.
30133
+ * This is a behaviour change from the pre-0.54.2 single-row layout
30134
+ * where the status message sat awkwardly between the contextual and
30135
+ * global hints, getting visually crushed.
29844
30136
  *
29845
- * Idle tips only fill the slot when no real status message is set so the
30137
+ * The separation matters because:
30138
+ * - status text and key hints serve different cognitive purposes
30139
+ * (read vs. scan) and competing for the same row makes both
30140
+ * harder to use,
30141
+ * - long status messages (especially errors / multi-clause loading
30142
+ * copy) no longer push global hints off screen or wrap into the
30143
+ * hint cluster,
30144
+ * - errors now keep the global hints visible — the user often
30145
+ * needs `?` / `:` / `q` to *recover* from the error.
30146
+ *
30147
+ * Idle tips fill row 2 only when no real status message is set so the
29846
30148
  * tip cycle never overwrites genuine workflow feedback.
29847
30149
  *
29848
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
29849
- * of #890. No behavior change.
30150
+ * Row 2 styling is kind-aware. Each statusKind gets its own theme
30151
+ * color and glyph prefix so the message is identifiable at a glance
30152
+ * — even with NO_COLOR set, the glyph alone communicates kind:
30153
+ *
30154
+ * loading → spinner + accent + bold
30155
+ * error → ✗ / ! + danger + bold
30156
+ * warning → ⚠ / ! + warning + bold
30157
+ * success → ✓ / + + success + bold
30158
+ * info → ℹ / i + info + bold
30159
+ * idle tip → no glyph + dim muted (passive)
30160
+ *
30161
+ * Pre-redesign success and loading both used `accent` (cyan), so the
30162
+ * user couldn't tell "done" from "in progress" by color alone. Each
30163
+ * kind now uses its dedicated theme color and ships an ASCII glyph
30164
+ * fallback for `theme.ascii` mode (TERM=dumb / vt100).
30165
+ *
30166
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30167
+ * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
30168
+ * colors + glyphs added in the same pass.
29850
30169
  */
29851
30170
  function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
29852
30171
  const { Box, Text } = components;
@@ -29878,50 +30197,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
29878
30197
  });
29879
30198
  // Real status messages always win; idle tips only fill the slot when it
29880
30199
  // would otherwise be empty.
29881
- const isLoading = Boolean(state.statusLoading && state.statusMessage);
29882
- const trailing = state.statusMessage || idleTip || '';
29883
- // Loading status gets a spinner prefix in front of the message —
29884
- // motion makes transient LLM calls (create-PR body, PR fetches,
29885
- // etc.) feel less frozen even when they're sub-second.
29886
- const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
29887
- const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
29888
- const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
30200
+ const hasStatusMessage = Boolean(state.statusMessage);
30201
+ const isLoading = Boolean(state.statusLoading && hasStatusMessage);
29889
30202
  const isError = state.statusKind === 'error';
30203
+ const isWarning = state.statusKind === 'warning';
29890
30204
  const isSuccess = state.statusKind === 'success';
29891
- const contextualText = isError
29892
- // Errors get the full footer width and a `✗` prefix so they read
29893
- // as alarming. We drop the contextual hints when an error is
29894
- // active they'd compete for attention with the message and
29895
- // long validator outputs (#907 polish: split-plan validator
29896
- // errors are often 100+ chars and got truncated against the hints).
29897
- ? `✗ ${state.statusMessage || ''}`
29898
- : `${hints.contextual.join(' ')}${status}`;
30205
+ // 'info' is the implicit kind when statusKind is undefined but
30206
+ // statusMessage is set it's a deliberate status update, not an
30207
+ // idle tip, so it gets info treatment rather than the dim fallback.
30208
+ const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
30209
+ const rawTrailing = state.statusMessage || idleTip || '';
30210
+ // Glyphs per kind so the message is identifiable even before reading
30211
+ // the color improves scan-ability and degrades gracefully when the
30212
+ // terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
30213
+ // = dumb / vt100) where unicode glyphs render as garbage.
30214
+ // loading → spinner (animated)
30215
+ // error → ✗ / !
30216
+ // warning → ⚠ / !
30217
+ // success → ✓ / +
30218
+ // info → ℹ / i
30219
+ // idle tip → no glyph (passive)
30220
+ const glyph = (() => {
30221
+ if (isLoading)
30222
+ return pickSpinnerFrame(spinnerFrame);
30223
+ if (isError)
30224
+ return theme.ascii ? '!' : '✗';
30225
+ if (isWarning)
30226
+ return theme.ascii ? '!' : '⚠';
30227
+ if (isSuccess)
30228
+ return theme.ascii ? '+' : '✓';
30229
+ if (isInfo)
30230
+ return theme.ascii ? 'i' : 'ℹ';
30231
+ return '';
30232
+ })();
30233
+ const statusBody = rawTrailing
30234
+ ? glyph
30235
+ ? `${glyph} ${rawTrailing}`
30236
+ : rawTrailing
30237
+ : '';
30238
+ // Row 2 color picks. Each kind gets its own theme color so success
30239
+ // and loading are visually distinct (was conflated under `accent`
30240
+ // pre-redesign — users couldn't tell "done" from "in progress").
30241
+ // loading → accent (cyan / preset blue)
30242
+ // error → danger (red / preset red)
30243
+ // warning → warning (yellow)
30244
+ // success → success (green)
30245
+ // info → info (blue / preset accent in light themes)
30246
+ // idle → undefined + dim (passive, blends with chrome)
30247
+ const statusColor = isError
30248
+ ? theme.colors.danger
30249
+ : isWarning
30250
+ ? theme.colors.warning
30251
+ : isSuccess
30252
+ ? theme.colors.success
30253
+ : isLoading
30254
+ ? theme.colors.accent
30255
+ : isInfo
30256
+ ? theme.colors.info
30257
+ : undefined;
30258
+ const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
30259
+ const statusDim = !statusBold;
30260
+ const hintsText = hints.contextual.join(' ');
29899
30261
  const globalText = hints.global.join(' · ');
29900
- // Error rendering: hide the global hints on the right so the
29901
- // message can wrap into that space. Success rendering: accent
29902
- // color on the message, hints stay visible. Default: existing
29903
- // muted styling.
29904
- const contextualColor = isError
29905
- ? 'red'
29906
- : isSuccess
29907
- ? theme.colors.accent
29908
- : theme.colors.muted;
29909
- return h(Box, {
29910
- flexDirection: 'row',
29911
- height: 2,
29912
- justifyContent: 'space-between',
29913
- paddingX: 1,
29914
- }, h(Text, {
29915
- color: contextualColor,
29916
- dimColor: !isError && !isSuccess,
29917
- bold: isError,
29918
- }, contextualText),
29919
- // Globals are dropped entirely when an error is on screen — that
29920
- // space is what the long message needs to render. They come back
29921
- // the moment the status flips to info / success / cleared.
29922
- isError
29923
- ? h(Text, undefined, '')
29924
- : h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
30262
+ return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
30263
+ // Row 1: contextual global hints. justifyContent pushes them
30264
+ // to opposite edges so the eye can scan each cluster as one
30265
+ // block instead of hunting through a single concatenated line.
30266
+ 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)),
30267
+ // Row 2: status / loading / idle tip / error. Empty Text keeps
30268
+ // the row reserved when nothing's set so the surrounding layout
30269
+ // doesn't shift as status flips on/off.
30270
+ h(Text, {
30271
+ color: statusColor,
30272
+ dimColor: statusDim,
30273
+ bold: statusBold,
30274
+ }, statusBody));
30275
+ }
30276
+
30277
+ const COMBINING_MARK_RANGES = [
30278
+ [0x0300, 0x036f],
30279
+ [0x1ab0, 0x1aff],
30280
+ [0x1dc0, 0x1dff],
30281
+ [0x20d0, 0x20ff],
30282
+ [0xfe20, 0xfe2f],
30283
+ ];
30284
+ const WIDE_CHARACTER_RANGES = [
30285
+ [0x1100, 0x115f],
30286
+ [0x2329, 0x232a],
30287
+ [0x2e80, 0xa4cf],
30288
+ [0xac00, 0xd7a3],
30289
+ [0xf900, 0xfaff],
30290
+ [0xfe10, 0xfe19],
30291
+ [0xfe30, 0xfe6f],
30292
+ [0xff00, 0xff60],
30293
+ [0xffe0, 0xffe6],
30294
+ [0x2600, 0x27bf],
30295
+ [0x1f000, 0x1f9ff],
30296
+ [0x20000, 0x3fffd],
30297
+ ];
30298
+ function isInRange(codePoint, ranges) {
30299
+ return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30300
+ }
30301
+ function characterWidth(character) {
30302
+ const codePoint = character.codePointAt(0) || 0;
30303
+ if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30304
+ return 0;
30305
+ }
30306
+ if (codePoint === 0x200d ||
30307
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30308
+ isInRange(codePoint, COMBINING_MARK_RANGES)) {
30309
+ return 0;
30310
+ }
30311
+ return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30312
+ }
30313
+ function cellWidth(value) {
30314
+ return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30315
+ }
30316
+ /**
30317
+ * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30318
+ * on whitespace where possible; falls back to mid-word splits when a single
30319
+ * word is wider than the budget. Preserves blank input as a single empty
30320
+ * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30321
+ */
30322
+ function wrapCells(value, width) {
30323
+ if (width < 1) {
30324
+ return [value];
30325
+ }
30326
+ if (cellWidth(value) <= width) {
30327
+ return [value];
30328
+ }
30329
+ const lines = [];
30330
+ let current = '';
30331
+ let currentWidth = 0;
30332
+ const flush = () => {
30333
+ if (current.length > 0) {
30334
+ lines.push(current);
30335
+ current = '';
30336
+ currentWidth = 0;
30337
+ }
30338
+ };
30339
+ // Tokenize into runs of whitespace + non-whitespace so we can keep word
30340
+ // boundaries when possible.
30341
+ const tokens = value.match(/\s+|\S+/g) || [];
30342
+ for (const token of tokens) {
30343
+ const tokenWidth = cellWidth(token);
30344
+ if (currentWidth + tokenWidth <= width) {
30345
+ current += token;
30346
+ currentWidth += tokenWidth;
30347
+ continue;
30348
+ }
30349
+ if (/^\s+$/.test(token)) {
30350
+ // Drop boundary whitespace at line breaks.
30351
+ flush();
30352
+ continue;
30353
+ }
30354
+ flush();
30355
+ if (tokenWidth <= width) {
30356
+ current = token;
30357
+ currentWidth = tokenWidth;
30358
+ continue;
30359
+ }
30360
+ // Word longer than budget — hard-split into chunks.
30361
+ let remaining = token;
30362
+ while (cellWidth(remaining) > width) {
30363
+ let chunk = '';
30364
+ let chunkWidth = 0;
30365
+ for (const character of Array.from(remaining)) {
30366
+ const charW = characterWidth(character);
30367
+ if (chunkWidth + charW > width)
30368
+ break;
30369
+ chunk += character;
30370
+ chunkWidth += charW;
30371
+ }
30372
+ lines.push(chunk);
30373
+ remaining = remaining.slice(chunk.length);
30374
+ }
30375
+ if (remaining.length > 0) {
30376
+ current = remaining;
30377
+ currentWidth = cellWidth(remaining);
30378
+ }
30379
+ }
30380
+ flush();
30381
+ return lines.length > 0 ? lines : [value];
30382
+ }
30383
+ function truncateCells(value, width) {
30384
+ if (width < 1) {
30385
+ return '';
30386
+ }
30387
+ if (cellWidth(value) <= width) {
30388
+ return value;
30389
+ }
30390
+ const suffix = width > 3 ? '...' : '';
30391
+ const available = width - cellWidth(suffix);
30392
+ let used = 0;
30393
+ let output = '';
30394
+ for (const character of Array.from(value)) {
30395
+ const nextWidth = characterWidth(character);
30396
+ if (used + nextWidth > available) {
30397
+ break;
30398
+ }
30399
+ output += character;
30400
+ used += nextWidth;
30401
+ }
30402
+ return `${output}${suffix}`;
30403
+ }
30404
+ /**
30405
+ * Truncate a file path so the filename (last segment) is preserved,
30406
+ * eliding middle directory segments with `…/` instead of dropping
30407
+ * end-of-string characters.
30408
+ *
30409
+ * `truncateCells` is the wrong tool for paths because it preserves the
30410
+ * START of the string and drops the END — losing the filename, which
30411
+ * is the most useful part. Example with `truncateCells`:
30412
+ *
30413
+ * "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
30414
+ *
30415
+ * `truncatePathCells` preserves the filename and elides middle:
30416
+ *
30417
+ * "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
30418
+ *
30419
+ * The algorithm tries successively-smaller prefixes (keeping the start
30420
+ * of the path, the filename, and replacing the dropped middle segments
30421
+ * with `…`) and returns the largest variant that fits. When even
30422
+ * `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
30423
+ * the abbreviated form — better to show end-of-name than start-of-path.
30424
+ *
30425
+ * For inputs without `/` separators, behaves identically to
30426
+ * `truncateCells`. Empty / width-0 cases match `truncateCells` too.
30427
+ *
30428
+ * @example
30429
+ * truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
30430
+ * truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
30431
+ * truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
30432
+ * truncatePathCells('plainname.ts', 8) // 'plain...'
30433
+ */
30434
+ function truncatePathCells(value, width) {
30435
+ if (width < 1)
30436
+ return '';
30437
+ if (cellWidth(value) <= width)
30438
+ return value;
30439
+ // No path structure to exploit — fall through to plain truncation.
30440
+ if (!value.includes('/'))
30441
+ return truncateCells(value, width);
30442
+ const segments = value.split('/');
30443
+ const filename = segments[segments.length - 1] ?? '';
30444
+ const prefix = segments.slice(0, -1);
30445
+ // Path is just '/filename' or has only the filename — no middle to
30446
+ // elide. Defer to plain truncation.
30447
+ if (prefix.length === 0)
30448
+ return truncateCells(value, width);
30449
+ // Walk from "keep all prefix segments except the deepest" down to
30450
+ // "keep no prefix segments." First variant that fits wins.
30451
+ for (let keep = prefix.length - 1; keep >= 0; keep--) {
30452
+ const candidate = keep === 0
30453
+ ? `…/${filename}`
30454
+ : `${prefix.slice(0, keep).join('/')}/…/${filename}`;
30455
+ if (cellWidth(candidate) <= width)
30456
+ return candidate;
30457
+ }
30458
+ // Even `…/<filename>` doesn't fit. Use plain truncation on that
30459
+ // form — preserves the leading `…/` so the user knows a path was
30460
+ // elided, then ellipsis-truncates the filename.
30461
+ return truncateCells(`…/${filename}`, width);
29925
30462
  }
29926
30463
 
29927
30464
  /**
@@ -30148,218 +30685,318 @@ function sidebarTabCount(tab, context) {
30148
30685
  }
30149
30686
  }
30150
30687
 
30151
- const COMBINING_MARK_RANGES = [
30152
- [0x0300, 0x036f],
30153
- [0x1ab0, 0x1aff],
30154
- [0x1dc0, 0x1dff],
30155
- [0x20d0, 0x20ff],
30156
- [0xfe20, 0xfe2f],
30157
- ];
30158
- const WIDE_CHARACTER_RANGES = [
30159
- [0x1100, 0x115f],
30160
- [0x2329, 0x232a],
30161
- [0x2e80, 0xa4cf],
30162
- [0xac00, 0xd7a3],
30163
- [0xf900, 0xfaff],
30164
- [0xfe10, 0xfe19],
30165
- [0xfe30, 0xfe6f],
30166
- [0xff00, 0xff60],
30167
- [0xffe0, 0xffe6],
30168
- [0x2600, 0x27bf],
30169
- [0x1f000, 0x1f9ff],
30170
- [0x20000, 0x3fffd],
30171
- ];
30172
- function isInRange(codePoint, ranges) {
30173
- return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30174
- }
30175
- function characterWidth(character) {
30176
- const codePoint = character.codePointAt(0) || 0;
30177
- if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30178
- return 0;
30179
- }
30180
- if (codePoint === 0x200d ||
30181
- (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30182
- isInRange(codePoint, COMBINING_MARK_RANGES)) {
30183
- return 0;
30184
- }
30185
- return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30186
- }
30187
- function cellWidth(value) {
30188
- return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30189
- }
30190
30688
  /**
30191
- * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30192
- * on whitespace where possible; falls back to mid-word splits when a single
30193
- * word is wider than the budget. Preserves blank input as a single empty
30194
- * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30689
+ * Header chip builder. Turns the workstation's title-bar state into an
30690
+ * ordered list of small visually-distinct chips:
30691
+ *
30692
+ * coco · gfargo/coco · main · ✓ clean · ⊘ no PR · [NORMAL]
30693
+ *
30694
+ * Pre-refactor the title bar concatenated every segment into a single
30695
+ * Text span, which made the eye read the whole thing as one run of
30696
+ * words (the same problem the footer had). Splitting into chips with a
30697
+ * fixed separator lets each segment carry its own color and lets the
30698
+ * user scan the bar in chunks — "what app, what repo, what branch,
30699
+ * how clean, what PR state, what mode" — instead of parsing left-to-
30700
+ * right.
30701
+ *
30702
+ * Why a separate module: the header runtime renders chips and handles
30703
+ * truncation; chip construction is pure transformation of state +
30704
+ * context + theme. Splitting them keeps the chips testable in
30705
+ * isolation and keeps the runtime small.
30706
+ *
30707
+ * Truncation strategy lives in the consumer, not here — when the total
30708
+ * width exceeds the column budget, the header falls back to the
30709
+ * pre-redesign single-fragment truncated string so the ellipsis can't
30710
+ * land mid-glyph. We always return the FULL chip list; the consumer
30711
+ * decides whether to drop chips, fall back, or render all of them.
30195
30712
  */
30196
- function wrapCells(value, width) {
30197
- if (width < 1) {
30198
- return [value];
30713
+ /**
30714
+ * Default separator inserted between chips by the consumer. Exported as
30715
+ * a constant so tests and width math agree on what they're measuring.
30716
+ * The trailing/leading spaces are part of the separator — `·` alone
30717
+ * would butt against adjacent chip labels.
30718
+ */
30719
+ const HEADER_CHIP_SEPARATOR = ' · ';
30720
+ /**
30721
+ * Build the ordered chip list for the header. Chips not relevant to the
30722
+ * current state (no PR loaded, no breadcrumb, no search input, …) are
30723
+ * omitted entirely rather than rendered as empty placeholders, so the
30724
+ * consumer can just `chips.map(render)` without checking for empties.
30725
+ */
30726
+ function buildHeaderChips(input) {
30727
+ const { theme } = input;
30728
+ const chips = [];
30729
+ // App label — the constant identity. Accent + bold so it anchors the
30730
+ // left edge of the bar.
30731
+ chips.push({
30732
+ id: 'app',
30733
+ label: input.appLabel,
30734
+ color: theme.colors.accent,
30735
+ dim: false,
30736
+ bold: true,
30737
+ });
30738
+ // Repo. Default color — it's contextual but not the headline.
30739
+ chips.push({
30740
+ id: 'repo',
30741
+ label: input.repo,
30742
+ color: undefined,
30743
+ dim: false,
30744
+ bold: false,
30745
+ });
30746
+ // Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
30747
+ // is identifiable even when the branch name is generic ("main" /
30748
+ // "master").
30749
+ const branchGlyph = theme.ascii ? 'git:' : '⎇';
30750
+ chips.push({
30751
+ id: 'branch',
30752
+ label: `${branchGlyph} ${input.branch}`,
30753
+ color: theme.colors.accent,
30754
+ dim: false,
30755
+ bold: true,
30756
+ });
30757
+ // Dirty/clean. Positive framing on clean (success color + ✓), warning
30758
+ // on dirty (warning color + ●). ASCII fallbacks keep the chip
30759
+ // identifiable on dumb terminals.
30760
+ const dirtyChip = input.dirty
30761
+ ? {
30762
+ id: 'dirty',
30763
+ label: theme.ascii ? '* dirty' : '● dirty',
30764
+ color: theme.colors.warning,
30765
+ dim: false,
30766
+ bold: false,
30767
+ }
30768
+ : {
30769
+ id: 'dirty',
30770
+ label: theme.ascii ? '+ clean' : '✓ clean',
30771
+ color: theme.colors.success,
30772
+ dim: false,
30773
+ bold: false,
30774
+ };
30775
+ chips.push(dirtyChip);
30776
+ // Bisect — only when active. Distinct chip so users entering the TUI
30777
+ // mid-bisect see it immediately (#784). Warning color because bisect
30778
+ // is an "in progress, requires user action" state.
30779
+ if (input.bisecting) {
30780
+ chips.push({
30781
+ id: 'bisecting',
30782
+ label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
30783
+ color: theme.colors.warning,
30784
+ dim: false,
30785
+ bold: true,
30786
+ });
30199
30787
  }
30200
- if (cellWidth(value) <= width) {
30201
- return [value];
30788
+ // PR state. When present, the chip uses the PR-state glyph + a short
30789
+ // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
30790
+ // "no PR" chip so users know the system DID look (vs. the bar just
30791
+ // being blank).
30792
+ if (input.pullRequest) {
30793
+ const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
30794
+ const stateLabel = input.pullRequest.isDraft
30795
+ ? 'DRAFT'
30796
+ : input.pullRequest.state.toUpperCase();
30797
+ const label = prGlyph.glyph
30798
+ ? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
30799
+ : `PR #${input.pullRequest.number} ${stateLabel}`;
30800
+ chips.push({
30801
+ id: 'pr',
30802
+ label,
30803
+ color: prGlyph.color,
30804
+ dim: prGlyph.dim,
30805
+ bold: false,
30806
+ });
30202
30807
  }
30203
- const lines = [];
30204
- let current = '';
30205
- let currentWidth = 0;
30206
- const flush = () => {
30207
- if (current.length > 0) {
30208
- lines.push(current);
30209
- current = '';
30210
- currentWidth = 0;
30211
- }
30212
- };
30213
- // Tokenize into runs of whitespace + non-whitespace so we can keep word
30214
- // boundaries when possible.
30215
- const tokens = value.match(/\s+|\S+/g) || [];
30216
- for (const token of tokens) {
30217
- const tokenWidth = cellWidth(token);
30218
- if (currentWidth + tokenWidth <= width) {
30219
- current += token;
30220
- currentWidth += tokenWidth;
30221
- continue;
30222
- }
30223
- if (/^\s+$/.test(token)) {
30224
- // Drop boundary whitespace at line breaks.
30225
- flush();
30226
- continue;
30227
- }
30228
- flush();
30229
- if (tokenWidth <= width) {
30230
- current = token;
30231
- currentWidth = tokenWidth;
30232
- continue;
30233
- }
30234
- // Word longer than budget — hard-split into chunks.
30235
- let remaining = token;
30236
- while (cellWidth(remaining) > width) {
30237
- let chunk = '';
30238
- let chunkWidth = 0;
30239
- for (const character of Array.from(remaining)) {
30240
- const charW = characterWidth(character);
30241
- if (chunkWidth + charW > width)
30242
- break;
30243
- chunk += character;
30244
- chunkWidth += charW;
30245
- }
30246
- lines.push(chunk);
30247
- remaining = remaining.slice(chunk.length);
30248
- }
30249
- if (remaining.length > 0) {
30250
- current = remaining;
30251
- currentWidth = cellWidth(remaining);
30252
- }
30808
+ else {
30809
+ chips.push({
30810
+ id: 'pr',
30811
+ label: theme.ascii ? '- no PR' : '⊘ no PR',
30812
+ color: theme.colors.muted,
30813
+ dim: true,
30814
+ bold: false,
30815
+ });
30253
30816
  }
30254
- flush();
30255
- return lines.length > 0 ? lines : [value];
30256
- }
30257
- function truncateCells(value, width) {
30258
- if (width < 1) {
30259
- return '';
30817
+ // View breadcrumb. Rendered only when there's content (`coco ui`
30818
+ // root view no breadcrumb chip; pushed into a sub-view → chip
30819
+ // appears). Comes AFTER PR so the "state" group (app/repo/branch/
30820
+ // dirty/PR) reads as one cluster and the "navigation" group (view
30821
+ // breadcrumb / loading) reads as a separate cluster.
30822
+ if (input.breadcrumb) {
30823
+ chips.push({
30824
+ id: 'view',
30825
+ label: input.breadcrumb,
30826
+ color: theme.colors.muted,
30827
+ dim: true,
30828
+ bold: false,
30829
+ });
30260
30830
  }
30261
- if (cellWidth(value) <= width) {
30262
- return value;
30831
+ if (input.loading) {
30832
+ chips.push({
30833
+ id: 'loading',
30834
+ label: input.loading.trim(),
30835
+ color: theme.colors.muted,
30836
+ dim: true,
30837
+ bold: false,
30838
+ });
30263
30839
  }
30264
- const suffix = width > 3 ? '...' : '';
30265
- const available = width - cellWidth(suffix);
30266
- let used = 0;
30267
- let output = '';
30268
- for (const character of Array.from(value)) {
30269
- const nextWidth = characterWidth(character);
30270
- if (used + nextWidth > available) {
30271
- break;
30272
- }
30273
- output += character;
30274
- used += nextWidth;
30840
+ // Mode the explicit input-mode indicator (#P2.2). Always present
30841
+ // so users never wonder why `q` doesn't quit while they're editing.
30842
+ // EDIT / FILTER use the warning color to signal "your keystrokes
30843
+ // mean something different right now"; NORMAL uses accent (matches
30844
+ // the app chip's home base).
30845
+ const modeColor = input.mode === 'NORMAL'
30846
+ ? theme.colors.accent
30847
+ : theme.colors.warning;
30848
+ chips.push({
30849
+ id: 'mode',
30850
+ label: `[${input.mode}]`,
30851
+ color: modeColor,
30852
+ dim: false,
30853
+ bold: true,
30854
+ });
30855
+ // Search — only when active. Dim so it doesn't compete with the
30856
+ // identity chips for attention; the user knows it's there because
30857
+ // they're typing into it.
30858
+ if (input.search) {
30859
+ chips.push({
30860
+ id: 'search',
30861
+ label: input.search,
30862
+ color: theme.colors.muted,
30863
+ dim: true,
30864
+ bold: false,
30865
+ });
30275
30866
  }
30276
- return `${output}${suffix}`;
30867
+ return chips;
30868
+ }
30869
+ /**
30870
+ * Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
30871
+ * between every pair. Used by the consumer to decide whether the
30872
+ * chip layout fits the column budget or whether to fall back to the
30873
+ * single-fragment truncated path.
30874
+ */
30875
+ function measureHeaderChipsWidth(chips) {
30876
+ if (chips.length === 0)
30877
+ return 0;
30878
+ const labels = chips.map((chip) => cellWidth(chip.label));
30879
+ const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
30880
+ return labels.reduce((sum, w) => sum + w, 0) + separators;
30277
30881
  }
30278
30882
 
30279
30883
  /**
30280
- * Title-bar renderer. Surfaces:
30281
- * - the app label (e.g. "coco ui")
30282
- * - current repo owner/name (or "local repository")
30283
- * - current branch + dirty / BISECTING flag
30284
- * - PR glyph + label when one is detected
30285
- * - breadcrumb of the view stack
30286
- * - loading hint for boot / context fetches
30287
- * - mode indicator: [NORMAL] / [EDIT] / [FILTER]
30288
- * - active filter / search input
30884
+ * Title-bar renderer. Surfaces the workstation's identity + navigation
30885
+ * state as a row of small visually-distinct chips:
30289
30886
  *
30290
- * Truncation: when the assembled title overruns the available columns we
30291
- * fall back to a single-fragment Text (truncating the joined string) so
30292
- * the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
30293
- * glyph in its own colored span when there's headroom.
30887
+ * coco · gfargo/coco · main · clean · ⊘ no PR · [NORMAL]
30294
30888
  *
30295
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
30296
- * of #890. No behavior change.
30889
+ * Per-chip color/glyph treatment lets the user scan in chunks ("what
30890
+ * app, what repo, what branch, how clean, what PR state, what mode")
30891
+ * instead of parsing one long sentence. Chip construction is in
30892
+ * `chrome/headerChips.ts`; this runtime just renders.
30893
+ *
30894
+ * Truncation: when the assembled chip row overruns the available
30895
+ * columns we fall back to a single Text fragment (truncating the
30896
+ * joined chip labels) so the ellipsis can't land mid-glyph. This is
30897
+ * the same defensive pattern the pre-redesign single-fragment code
30898
+ * used, applied at the chip-list level instead of the inline glyph
30899
+ * split.
30900
+ *
30901
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30902
+ * 5a.7 of #890. Chip restructuring introduced post-0.54.2.
30297
30903
  */
30298
30904
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
30299
30905
  const { Box, Text } = components;
30906
+ // Pull the source state into the small "describe what to render"
30907
+ // shape the chip builder expects. Keeps the runtime decoupled from
30908
+ // the chip layout — the builder doesn't know about LogInkState /
30909
+ // LogInkContext, just plain values.
30300
30910
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
30301
- // #784 surface bisect-in-progress in the title bar so users entering
30302
- // the TUI mid-bisect see it immediately, before they navigate to gB.
30303
- const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
30304
- const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
30911
+ const dirty = Boolean(context.branches?.dirty);
30912
+ const bisecting = Boolean(context.bisect?.active);
30305
30913
  const repo = context.provider?.repository.owner && context.provider.repository.name
30306
30914
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
30307
30915
  : 'local repository';
30308
30916
  const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
30309
- const prGlyph = prInfo ? getPullRequestStateGlyph(prInfo, theme) : null;
30310
- const prLabel = prInfo
30311
- ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
30312
- : 'no PR';
30313
- const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
30314
- // Boot loading wins over the per-context loading hint because it
30315
- // tells the user the headline thing they care about (commits aren't
30316
- // ready yet) — the context fetches finish independently and surface
30317
- // their own per-section loading copy in the sidebars.
30917
+ // Boot loading wins over the per-context loading hint — same
30918
+ // priority as pre-redesign. Context fetches still surface their own
30919
+ // copy in the sidebars.
30318
30920
  const loading = state.bootLoading
30319
- ? ' loading commits'
30320
- : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
30921
+ ? 'loading commits'
30922
+ : isLogInkContextLoading(contextStatus) ? 'loading context' : '';
30321
30923
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
30322
30924
  const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
30323
- // Repo breadcrumb (when nested) comes first so the user sees which
30324
- // submodule they're in at a glance, then the view breadcrumb (when
30325
- // pushed deeper than the root view). The truncate fallback in the
30326
- // title row still applies — when both fight for space, the ellipsis
30327
- // lands at the end of whichever segment overflows.
30328
30925
  const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
30329
- // Mode indicator (P2.2) — surfaces the current input mode so users
30330
- // never wonder why `q` doesn't quit while they're editing or filtering.
30331
30926
  const mode = state.commitCompose.editing
30332
- ? '[EDIT]'
30927
+ ? 'EDIT'
30333
30928
  : state.filterMode
30334
- ? '[FILTER]'
30335
- : '[NORMAL]';
30336
- const titlePrefix = `${appLabel} ${repo} ${branch} ${dirty} `;
30337
- const glyphPart = prGlyph?.glyph ? `${prGlyph.glyph} ` : '';
30338
- const titleSuffix = `${view}${loading}`;
30339
- const fullTitle = `${titlePrefix}${glyphPart}${prLabel}${titleSuffix}`;
30340
- const titleBudget = columns - mode.length - 4;
30341
- const truncatedTitle = truncateCells(fullTitle, titleBudget);
30342
- // Only split into colored fragments when the prefix + glyph + label all
30343
- // fit unmodified — otherwise the truncate ellipsis can land mid-fragment
30344
- // and we'd render half a glyph in the wrong color.
30345
- const splitFragments = truncatedTitle === fullTitle && glyphPart.length > 0;
30346
- const modeColor = theme.noColor
30347
- ? undefined
30348
- : state.filterMode || state.commitCompose.editing
30349
- ? theme.colors.warning
30350
- : theme.colors.accent;
30929
+ ? 'FILTER'
30930
+ : 'NORMAL';
30931
+ const search = state.filterMode
30932
+ ? `search: ${state.filter}_`
30933
+ : state.filter
30934
+ ? `filter: ${state.filter}`
30935
+ : '';
30936
+ const chips = buildHeaderChips({
30937
+ appLabel,
30938
+ repo,
30939
+ branch,
30940
+ dirty,
30941
+ bisecting,
30942
+ pullRequest: prInfo ? {
30943
+ number: prInfo.number,
30944
+ state: prInfo.state,
30945
+ isDraft: prInfo.isDraft,
30946
+ } : undefined,
30947
+ breadcrumb: view,
30948
+ loading,
30949
+ mode,
30950
+ search: search ? truncateCells(search, 36) : '',
30951
+ theme,
30952
+ });
30953
+ // Truncation budget. Header line gets the full terminal width minus
30954
+ // the box's horizontal padding (2 cells) and a small safety margin.
30955
+ const budget = Math.max(0, columns - 4);
30956
+ const chipsWidth = measureHeaderChipsWidth(chips);
30351
30957
  return h(Box, {
30352
30958
  borderColor: theme.colors.border,
30353
30959
  borderStyle: theme.borderStyle,
30354
30960
  height: 3,
30355
30961
  paddingX: 1,
30356
- }, splitFragments
30357
- ? h(Text, { bold: true, color: theme.colors.accent }, titlePrefix)
30358
- : h(Text, { bold: true, color: theme.colors.accent }, truncatedTitle), splitFragments
30359
- ? h(Text, { bold: true, color: prGlyph?.color, dimColor: prGlyph?.dim }, glyphPart)
30360
- : undefined, splitFragments
30361
- ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
30362
- : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncateCells(search, 36)}`) : undefined);
30962
+ }, chipsWidth <= budget
30963
+ ? renderChipRow(h, Text, chips)
30964
+ : renderFallback(h, Text, chips, theme, budget));
30965
+ }
30966
+ /**
30967
+ * Render every chip as its own Text span with its own color/style,
30968
+ * interleaved with dim separator spans. This is the path used when
30969
+ * everything fits — the eye gets the full chip treatment.
30970
+ */
30971
+ function renderChipRow(h, Text, chips) {
30972
+ const nodes = [];
30973
+ chips.forEach((chip, index) => {
30974
+ if (index > 0) {
30975
+ // Separator is intentionally dim so the eye can use it as a
30976
+ // visual delimiter without it competing with chip labels for
30977
+ // attention.
30978
+ nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
30979
+ }
30980
+ nodes.push(h(Text, {
30981
+ key: chip.id,
30982
+ color: chip.color,
30983
+ dimColor: chip.dim,
30984
+ bold: chip.bold,
30985
+ }, chip.label));
30986
+ });
30987
+ return nodes;
30988
+ }
30989
+ /**
30990
+ * Fallback path for narrow terminals. Concatenates every chip label
30991
+ * with separators, then truncates the whole string with
30992
+ * `truncateCells` so the ellipsis lands at a cell boundary. Loses the
30993
+ * per-chip color treatment in exchange for guaranteed legibility on
30994
+ * narrow displays — the same trade-off the pre-redesign single-
30995
+ * fragment code made for its inline glyph color split.
30996
+ */
30997
+ function renderFallback(h, Text, chips, theme, budget) {
30998
+ const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
30999
+ return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
30363
31000
  }
30364
31001
 
30365
31002
  /**
@@ -30582,10 +31219,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
30582
31219
  const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
30583
31220
  const fileRows = worktree.files.slice(0, 12).map((file, index) => {
30584
31221
  const codes = `${file.indexStatus}${file.worktreeStatus}`;
31222
+ // Smart path truncation: keep the leading status codes and elide
31223
+ // middle directory segments to preserve the filename. Falls back
31224
+ // to plain truncation when the codes + a meaningful filename
31225
+ // don't both fit. Same shape as the detail surface so all the
31226
+ // status-row renderings elide consistently.
31227
+ const prefix = ` ${codes} `;
31228
+ const totalBudget = width - 4;
31229
+ const pathBudget = totalBudget - cellWidth(prefix);
31230
+ const label = pathBudget >= 8
31231
+ ? `${prefix}${truncatePathCells(file.path, pathBudget)}`
31232
+ : truncateCells(`${prefix}${file.path}`, totalBudget);
30585
31233
  return h(Text, {
30586
31234
  key: `tab-status-file-${index}`,
30587
31235
  color: colorOf(file.state),
30588
- }, truncateCells(` ${codes} ${file.path}`, width - 4));
31236
+ }, label);
30589
31237
  });
30590
31238
  return [
30591
31239
  summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
@@ -31928,7 +32576,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
31928
32576
  color: theme.noColor ? undefined : theme.colors.accent,
31929
32577
  backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
31930
32578
  inverse: isActive && focused,
31931
- }, truncateCells(`${arrow}${headerFile.path}`, width - 4));
32579
+ }, (() => {
32580
+ // Smart path truncation for the diff file header: keep
32581
+ // the leading arrow glyph and elide middle path
32582
+ // segments so the filename is never lost. Falls back to
32583
+ // plain truncation when there isn't room for a
32584
+ // meaningful filename.
32585
+ const pathBudget = (width - 4) - cellWidth(arrow);
32586
+ return pathBudget >= 8
32587
+ ? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
32588
+ : truncateCells(`${arrow}${headerFile.path}`, width - 4);
32589
+ })());
31932
32590
  }
31933
32591
  return h(Text, {
31934
32592
  key: `stash-diff-line-${absoluteIndex}`,
@@ -35409,6 +36067,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
35409
36067
  });
35410
36068
  return out;
35411
36069
  }
36070
+ /**
36071
+ * Compose a `<prefix><path><suffix>` line where the path gets smart
36072
+ * middle-elision truncation if needed, while the fixed prefix/suffix
36073
+ * decorations stay intact. Falls back to plain whole-line truncation
36074
+ * when the suffix decorations consume too much of the budget for the
36075
+ * path-aware variant to leave a meaningful filename.
36076
+ *
36077
+ * Used by the changed-files list AND the compose-context staged /
36078
+ * unstaged sections so all three places elide identically — same
36079
+ * floor (8 cells), same fallback shape.
36080
+ */
36081
+ function smartPathLabel(prefix, path, suffix, totalBudget) {
36082
+ const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
36083
+ if (pathBudget >= 8) {
36084
+ return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
36085
+ }
36086
+ return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
36087
+ }
35412
36088
  /**
35413
36089
  * Render a list of changed files with status-code colors and stats. Used
35414
36090
  * by both the history inspector and the commit-diff detail panel so the
@@ -35436,13 +36112,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
35436
36112
  // in `lfsPointer.ts` so even rename / mode-only rows are
35437
36113
  // flagged.
35438
36114
  const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
35439
- const label = `${cursor} ${statusCode} ${file.path}${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36115
+ // Smart path truncation via `smartPathLabel`: keeps the cursor +
36116
+ // status-code prefix and the stats/badge suffix intact, gives
36117
+ // the path's remaining width budget to middle-elision so the
36118
+ // filename survives instead of getting blunt-truncated off the
36119
+ // end (the issue users hit when inspector paths read like
36120
+ // `src/commands/log/da...`).
36121
+ const labelPrefix = `${cursor} ${statusCode} `;
36122
+ const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36123
+ const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
35440
36124
  return h(Text, {
35441
36125
  key: `commit-file-${index}`,
35442
36126
  color: statusCodeColor(file.status, theme),
35443
36127
  inverse: isSelected && focused && !theme.noColor,
35444
36128
  bold: isSelected,
35445
- }, truncateCells(label, width - 4));
36129
+ }, label);
35446
36130
  });
35447
36131
  }
35448
36132
  function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
@@ -35718,7 +36402,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35718
36402
  ...stagedFiles.map((file, index) => h(Text, {
35719
36403
  key: `compose-context-staged-${index}`,
35720
36404
  color: theme.noColor ? undefined : theme.colors.gitAdded,
35721
- }, truncateCells(` ${file.indexStatus} ${file.path}`, width - 4))),
36405
+ }, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
35722
36406
  h(Text, { key: 'compose-context-staged-spacer' }, ''),
35723
36407
  ]
35724
36408
  : []), ...(unstagedFiles.length
@@ -35727,7 +36411,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35727
36411
  ...unstagedFiles.map((file, index) => h(Text, {
35728
36412
  key: `compose-context-unstaged-${index}`,
35729
36413
  color: theme.noColor ? undefined : theme.colors.gitModified,
35730
- }, truncateCells(` ${file.worktreeStatus} ${file.path}`, width - 4))),
36414
+ }, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
35731
36415
  ]
35732
36416
  : !stagedFiles.length && !loadingWorktree
35733
36417
  ? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
@@ -36627,7 +37311,7 @@ function LogInkApp(deps) {
36627
37311
  if (cancelled || !mountedRef.current)
36628
37312
  return;
36629
37313
  const message = error instanceof Error ? error.message : String(error);
36630
- dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
37314
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
36631
37315
  dispatch({ type: 'setBootLoading', value: false });
36632
37316
  });
36633
37317
  return () => {
@@ -36695,8 +37379,15 @@ function LogInkApp(deps) {
36695
37379
  ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
36696
37380
  ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
36697
37381
  };
37382
+ // Stash commits as graph roots so post-operation refreshes
37383
+ // keep the same rich graph the boot loader assembled. Without
37384
+ // this, every commit / split-apply / etc. would drop stash
37385
+ // anchors and the cursor-syncs-history effect would degrade
37386
+ // back to "tip not in loaded window" for older stashes.
37387
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
36698
37388
  const fresh = await getLogRows(git, mergedArgv, {
36699
37389
  limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
37390
+ extraRefs: stashHashes,
36700
37391
  });
36701
37392
  if (mountedRef.current && fresh) {
36702
37393
  dispatch({ type: 'replaceRows', rows: fresh });
@@ -37262,12 +37953,33 @@ function LogInkApp(deps) {
37262
37953
  // fetched yet); a status hint surfaces in that case so the user
37263
37954
  // knows to toggle full graph or load older commits.
37264
37955
  const lastSyncedHashRef = React.useRef(undefined);
37956
+ // Tracks which target hashes we've already anchored a `git log`
37957
+ // fetch on (#1034 follow-up). When the cursor-syncs-history effect
37958
+ // sees a target whose hash isn't in the loaded window AND isn't in
37959
+ // this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
37960
+ // here. After the fetch resolves and rows are appended, the effect
37961
+ // re-fires; if the target STILL isn't loaded the resolver sees the
37962
+ // hash in this set and returns `unreachable` instead of looping.
37963
+ //
37964
+ // Stored as a ref because (a) the resolver only ever reads it and
37965
+ // (b) component re-renders on state.filteredCommits change are the
37966
+ // re-fire trigger; storing here in state would add a redundant
37967
+ // render per attempt.
37968
+ const attemptedContextHashesRef = React.useRef(new Set());
37969
+ const loadCommitContextRef = React.useRef(null);
37265
37970
  React.useEffect(() => {
37266
37971
  const onBranchTab = state.activeView === 'branches' ||
37267
37972
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37268
37973
  const onTagTab = state.activeView === 'tags' ||
37269
37974
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37270
- if (!onBranchTab && !onTagTab)
37975
+ // User-reported gap: cursoring a stash didn't sync the history
37976
+ // cursor the way cursoring a branch / tag did. Same auto-jump
37977
+ // affordance now extends to stashes; the stash's commit hash IS
37978
+ // the row to land on (stashes are commits living off the
37979
+ // `refs/stash` tree, visible under `--all` / fullGraph).
37980
+ const onStashTab = state.activeView === 'stash' ||
37981
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
37982
+ if (!onBranchTab && !onTagTab && !onStashTab)
37271
37983
  return;
37272
37984
  let targetHash;
37273
37985
  let targetLabel;
@@ -37293,51 +38005,117 @@ function LogInkApp(deps) {
37293
38005
  targetLabel = `tag ${tag.name}`;
37294
38006
  }
37295
38007
  }
37296
- if (!targetHash)
37297
- return;
37298
- // Skip the dispatch + status churn when the cursor hasn't
37299
- // actually changed which commit it's targeting (the case for
37300
- // rapid navigation through a cluster of branches that all point
37301
- // at the same commit). Without this guard the user sees a stream
37302
- // of "Synced history to <branch> tip" status messages even
37303
- // though the history cursor never moved.
37304
- if (targetHash === lastSyncedHashRef.current)
37305
- return;
37306
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
37307
- if (loaded) {
37308
- lastSyncedHashRef.current = targetHash;
37309
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
37310
- // Confirmation status message so the user gets feedback even
37311
- // when the dedicated branches / tags view is occupying the
37312
- // main panel and the history cursor moves invisibly behind it.
37313
- dispatch({
37314
- type: 'setStatus',
37315
- value: `Synced history to ${targetLabel} tip`,
37316
- });
38008
+ else if (onStashTab) {
38009
+ const all = context.stashes?.stashes || [];
38010
+ const visible = state.filter
38011
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
38012
+ : all;
38013
+ const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
38014
+ if (stash) {
38015
+ // Two-step fallback chain for stash cursor sync:
38016
+ //
38017
+ // 1. Try `baseHash` (the branch tip the stash was created
38018
+ // from). This answers the user-visible question "where
38019
+ // in larger git history was this stash made?" — that's
38020
+ // the branch origin point, not the stash's own merge-
38021
+ // commit row off in `refs/stash`. Base commits live on
38022
+ // regular branches so they're almost always in the
38023
+ // loaded window.
38024
+ //
38025
+ // 2. If `baseHash` isn't in the loaded window (the stash's
38026
+ // base branch was deleted, or the base is older than
38027
+ // the 1000-commit cap), fall back to `stash.hash`
38028
+ // itself. The stash commit was added as an extraRef so
38029
+ // it's reachable from the graph if it fits the window.
38030
+ //
38031
+ // Only after BOTH miss does the effect report "tip not in
38032
+ // loaded window." The label flips to mention "base" vs the
38033
+ // stash commit so the user knows what they're looking at.
38034
+ // hashesMatchAny handles the short-hash auto-extension
38035
+ // mismatch between `git stash list --format=%h` (stash hash)
38036
+ // and `git log --pretty=format:%h` (history row). Same
38037
+ // hazard as the branch/tag cursor sync — see src/git/hashes.ts.
38038
+ const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
38039
+ const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
38040
+ if (baseLoaded) {
38041
+ targetHash = stash.baseHash;
38042
+ targetLabel = `${stash.ref}'s base`;
38043
+ }
38044
+ else if (hashLoaded) {
38045
+ targetHash = stash.hash;
38046
+ targetLabel = stash.ref;
38047
+ }
38048
+ else {
38049
+ // Neither in window — set to baseHash so the standard
38050
+ // "not in loaded window" message fires with a meaningful
38051
+ // label (the base is what the user actually wants to see).
38052
+ targetHash = stash.baseHash || stash.hash;
38053
+ targetLabel = stash.ref;
38054
+ }
38055
+ }
37317
38056
  }
37318
- else {
37319
- dispatch({
37320
- type: 'setStatus',
37321
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
37322
- });
38057
+ // Delegate the actual decision to the pure resolver so the
38058
+ // logic is testable in isolation. The effect just performs the
38059
+ // resolver's chosen action.
38060
+ const decision = resolveCursorSyncDecision({
38061
+ target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
38062
+ loadedHashes: buildLoadedHashSet(state.filteredCommits),
38063
+ lastSyncedHash: lastSyncedHashRef.current,
38064
+ attemptedContextHashes: attemptedContextHashesRef.current,
38065
+ });
38066
+ switch (decision.type) {
38067
+ case 'noop':
38068
+ return;
38069
+ case 'jump':
38070
+ lastSyncedHashRef.current = decision.hash;
38071
+ dispatch({ type: 'selectCommitByHash', hash: decision.hash });
38072
+ dispatch({
38073
+ type: 'setStatus',
38074
+ value: `Synced history to ${decision.label} tip`,
38075
+ });
38076
+ return;
38077
+ case 'load-context':
38078
+ // Mark the hash as attempted BEFORE firing the load so a
38079
+ // re-fire of this effect (state.filteredCommits change while
38080
+ // the load is in flight) doesn't kick off a duplicate
38081
+ // request. The resolver sees the hash in the set and
38082
+ // returns `noop` until the load completes; on completion the
38083
+ // appendRows triggers a final re-fire that either jumps or
38084
+ // returns `unreachable`.
38085
+ attemptedContextHashesRef.current.add(decision.target.hash);
38086
+ void loadCommitContextRef.current?.(decision.target);
38087
+ return;
38088
+ case 'unreachable':
38089
+ dispatch({
38090
+ type: 'setStatus',
38091
+ value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
38092
+ kind: 'warning',
38093
+ });
38094
+ return;
37323
38095
  }
37324
38096
  }, [
37325
- dispatch, context.branches, context.tags,
38097
+ dispatch, context.branches, context.tags, context.stashes,
37326
38098
  state.activeView, state.focus, state.sidebarTab,
37327
- state.selectedBranchIndex, state.selectedTagIndex,
38099
+ state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
37328
38100
  state.branchSort, state.tagSort, state.filter,
37329
38101
  state.filteredCommits,
37330
38102
  ]);
37331
38103
  // Reset the dedup ref when the user moves focus away from the
37332
- // sidebar branches / tags tab so re-entering re-fires the sync
37333
- // even if the cursored branch is the same as before.
38104
+ // sidebar branches / tags / stashes tab so re-entering re-fires the
38105
+ // sync even if the cursored row is the same as before.
37334
38106
  React.useEffect(() => {
37335
38107
  const onBranchTab = state.activeView === 'branches' ||
37336
38108
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37337
38109
  const onTagTab = state.activeView === 'tags' ||
37338
38110
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37339
- if (!onBranchTab && !onTagTab) {
38111
+ const onStashTab = state.activeView === 'stash' ||
38112
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
38113
+ if (!onBranchTab && !onTagTab && !onStashTab) {
37340
38114
  lastSyncedHashRef.current = undefined;
38115
+ // Drop any context-load attempt tracking too. If the user
38116
+ // navigates back later we want to retry rather than show
38117
+ // "unreachable" based on a stale attempted-set.
38118
+ attemptedContextHashesRef.current = new Set();
37341
38119
  }
37342
38120
  }, [state.activeView, state.focus, state.sidebarTab]);
37343
38121
  React.useEffect(() => {
@@ -37368,7 +38146,7 @@ function LogInkApp(deps) {
37368
38146
  ]);
37369
38147
  const toggleSelectedFileStage = React.useCallback(async () => {
37370
38148
  if (!selectedWorktreeFile) {
37371
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38149
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37372
38150
  return;
37373
38151
  }
37374
38152
  dispatch({ type: 'setStatus', value: 'updating file stage state' });
@@ -37383,7 +38161,7 @@ function LogInkApp(deps) {
37383
38161
  const toggleSelectedHunkStage = React.useCallback(async () => {
37384
38162
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37385
38163
  if (!selectedHunk) {
37386
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38164
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37387
38165
  return;
37388
38166
  }
37389
38167
  dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
@@ -37397,6 +38175,7 @@ function LogInkApp(deps) {
37397
38175
  dispatch({
37398
38176
  type: 'setStatus',
37399
38177
  value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
38178
+ kind: 'success',
37400
38179
  });
37401
38180
  await refreshWorktreeContext();
37402
38181
  setWorktreeDiff(undefined);
@@ -37406,12 +38185,13 @@ function LogInkApp(deps) {
37406
38185
  dispatch({
37407
38186
  type: 'setStatus',
37408
38187
  value: error.message || 'failed to update hunk stage state',
38188
+ kind: 'error',
37409
38189
  });
37410
38190
  }
37411
38191
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37412
38192
  const revertSelectedFile = React.useCallback(async () => {
37413
38193
  if (!selectedWorktreeFile) {
37414
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38194
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37415
38195
  return;
37416
38196
  }
37417
38197
  dispatch({ type: 'setStatus', value: 'reverting selected file' });
@@ -37424,13 +38204,13 @@ function LogInkApp(deps) {
37424
38204
  const revertSelectedHunk = React.useCallback(async () => {
37425
38205
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37426
38206
  if (!selectedHunk) {
37427
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38207
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37428
38208
  return;
37429
38209
  }
37430
38210
  dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
37431
38211
  try {
37432
38212
  await revertHunk(git, selectedHunk);
37433
- dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}` });
38213
+ dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
37434
38214
  await refreshWorktreeContext();
37435
38215
  setWorktreeDiff(undefined);
37436
38216
  setWorktreeHunks(undefined);
@@ -37439,13 +38219,14 @@ function LogInkApp(deps) {
37439
38219
  dispatch({
37440
38220
  type: 'setStatus',
37441
38221
  value: error.message || 'failed to revert hunk',
38222
+ kind: 'error',
37442
38223
  });
37443
38224
  }
37444
38225
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37445
38226
  const createCommitFromCompose = React.useCallback(async () => {
37446
38227
  const stagedCount = context.worktree?.stagedCount || 0;
37447
38228
  if (!stagedCount) {
37448
- dispatch({ type: 'setStatus', value: 'stage changes before committing' });
38229
+ dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
37449
38230
  return;
37450
38231
  }
37451
38232
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
@@ -37537,12 +38318,12 @@ function LogInkApp(deps) {
37537
38318
  // dispatches because the user already knows what happened.
37538
38319
  if (result.cancelled) {
37539
38320
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37540
- dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
38321
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
37541
38322
  return;
37542
38323
  }
37543
38324
  if (result.ok && result.draft) {
37544
38325
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37545
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
38326
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
37546
38327
  return;
37547
38328
  }
37548
38329
  dispatch({
@@ -37626,7 +38407,7 @@ function LogInkApp(deps) {
37626
38407
  const startCreatePullRequest = React.useCallback(async () => {
37627
38408
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37628
38409
  if (!head) {
37629
- dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
38410
+ dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
37630
38411
  return;
37631
38412
  }
37632
38413
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37634,11 +38415,12 @@ function LogInkApp(deps) {
37634
38415
  dispatch({
37635
38416
  type: 'setStatus',
37636
38417
  value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
38418
+ kind: 'warning',
37637
38419
  });
37638
38420
  return;
37639
38421
  }
37640
38422
  if (head === defaultBranch) {
37641
- dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.` });
38423
+ dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
37642
38424
  return;
37643
38425
  }
37644
38426
  if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
@@ -37648,6 +38430,7 @@ function LogInkApp(deps) {
37648
38430
  value: existing
37649
38431
  ? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
37650
38432
  : `A pull request is already open for ${head}.`,
38433
+ kind: 'warning',
37651
38434
  });
37652
38435
  return;
37653
38436
  }
@@ -37688,10 +38471,10 @@ function LogInkApp(deps) {
37688
38471
  const initialBody = body.body || '';
37689
38472
  const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37690
38473
  if (!body.ok) {
37691
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
38474
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
37692
38475
  }
37693
38476
  else {
37694
- dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
38477
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
37695
38478
  }
37696
38479
  // Audit finding #11: clear the pending flag BEFORE opening the
37697
38480
  // prompt. If a future refactor adds an `await` between the flag
@@ -37758,17 +38541,18 @@ function LogInkApp(deps) {
37758
38541
  const yankText = React.useCallback(async (value, label) => {
37759
38542
  const clipboard = clipboardRunner || defaultClipboardRunner;
37760
38543
  if (!value) {
37761
- dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.` });
38544
+ dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
37762
38545
  return;
37763
38546
  }
37764
38547
  try {
37765
38548
  await clipboard(value);
37766
- dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.` });
38549
+ dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
37767
38550
  }
37768
38551
  catch (error) {
37769
38552
  dispatch({
37770
38553
  type: 'setStatus',
37771
38554
  value: `Copy failed (${label}): ${error.message}`,
38555
+ kind: 'error',
37772
38556
  });
37773
38557
  }
37774
38558
  }, [clipboardRunner, dispatch]);
@@ -37792,7 +38576,7 @@ function LogInkApp(deps) {
37792
38576
  const startChangelogView = React.useCallback(async (options = {}) => {
37793
38577
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37794
38578
  if (!head) {
37795
- dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
38579
+ dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
37796
38580
  return;
37797
38581
  }
37798
38582
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37844,7 +38628,7 @@ function LogInkApp(deps) {
37844
38628
  baseLabel,
37845
38629
  error: result.message,
37846
38630
  });
37847
- dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}` });
38631
+ dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
37848
38632
  return;
37849
38633
  }
37850
38634
  dispatch({
@@ -37859,6 +38643,7 @@ function LogInkApp(deps) {
37859
38643
  dispatch({
37860
38644
  type: 'setStatus',
37861
38645
  value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
38646
+ kind: 'success',
37862
38647
  });
37863
38648
  }, [
37864
38649
  context.branches?.currentBranch,
@@ -37879,7 +38664,7 @@ function LogInkApp(deps) {
37879
38664
  const yankChangelog = React.useCallback(() => {
37880
38665
  const text = state.changelogView.text;
37881
38666
  if (!text) {
37882
- dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
38667
+ dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
37883
38668
  return;
37884
38669
  }
37885
38670
  void yankText(text, 'changelog');
@@ -37892,7 +38677,7 @@ function LogInkApp(deps) {
37892
38677
  const openChangelogInEditor = React.useCallback(() => {
37893
38678
  const current = state.changelogView.text;
37894
38679
  if (current === undefined) {
37895
- dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
38680
+ dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
37896
38681
  return;
37897
38682
  }
37898
38683
  let dir;
@@ -37903,6 +38688,7 @@ function LogInkApp(deps) {
37903
38688
  dispatch({
37904
38689
  type: 'setStatus',
37905
38690
  value: `Failed to create temp file for editor: ${error.message}`,
38691
+ kind: 'error',
37906
38692
  });
37907
38693
  return;
37908
38694
  }
@@ -37914,6 +38700,7 @@ function LogInkApp(deps) {
37914
38700
  dispatch({
37915
38701
  type: 'setStatus',
37916
38702
  value: `Failed to seed temp file: ${error.message}`,
38703
+ kind: 'error',
37917
38704
  });
37918
38705
  try {
37919
38706
  rmSync(dir, { recursive: true, force: true });
@@ -37937,13 +38724,13 @@ function LogInkApp(deps) {
37937
38724
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37938
38725
  const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
37939
38726
  if (result.error) {
37940
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38727
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37941
38728
  }
37942
38729
  else if (result.signal) {
37943
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38730
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37944
38731
  }
37945
38732
  else if (typeof result.status === 'number' && result.status !== 0) {
37946
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38733
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37947
38734
  }
37948
38735
  else {
37949
38736
  editorOk = true;
@@ -37958,12 +38745,13 @@ function LogInkApp(deps) {
37958
38745
  try {
37959
38746
  const content = readFileSync$1(file, 'utf8');
37960
38747
  dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
37961
- dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
38748
+ dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
37962
38749
  }
37963
38750
  catch (error) {
37964
38751
  dispatch({
37965
38752
  type: 'setStatus',
37966
38753
  value: `Failed to read back edited changelog: ${error.message}`,
38754
+ kind: 'error',
37967
38755
  });
37968
38756
  }
37969
38757
  }
@@ -38004,19 +38792,19 @@ function LogInkApp(deps) {
38004
38792
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
38005
38793
  const result = spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
38006
38794
  if (result.error) {
38007
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38795
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
38008
38796
  }
38009
38797
  else if (result.signal) {
38010
38798
  // Editor was killed by a signal (e.g. ^C, SIGTERM). status is
38011
38799
  // null in this case, so the old `status !== 0` check would
38012
38800
  // mistakenly fall through to the success branch.
38013
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38801
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
38014
38802
  }
38015
38803
  else if (typeof result.status === 'number' && result.status !== 0) {
38016
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38804
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
38017
38805
  }
38018
38806
  else {
38019
- dispatch({ type: 'setStatus', value: `Edited ${path}` });
38807
+ dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
38020
38808
  }
38021
38809
  }
38022
38810
  finally {
@@ -38063,6 +38851,7 @@ function LogInkApp(deps) {
38063
38851
  dispatch({
38064
38852
  type: 'setStatus',
38065
38853
  value: `Failed to create temp file for editor: ${error.message}`,
38854
+ kind: 'error',
38066
38855
  });
38067
38856
  return;
38068
38857
  }
@@ -38074,6 +38863,7 @@ function LogInkApp(deps) {
38074
38863
  dispatch({
38075
38864
  type: 'setStatus',
38076
38865
  value: `Failed to seed temp file: ${error.message}`,
38866
+ kind: 'error',
38077
38867
  });
38078
38868
  try {
38079
38869
  rmSync(dir, { recursive: true, force: true });
@@ -38097,13 +38887,13 @@ function LogInkApp(deps) {
38097
38887
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
38098
38888
  const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
38099
38889
  if (result.error) {
38100
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38890
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
38101
38891
  }
38102
38892
  else if (result.signal) {
38103
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38893
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
38104
38894
  }
38105
38895
  else if (typeof result.status === 'number' && result.status !== 0) {
38106
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38896
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
38107
38897
  }
38108
38898
  else {
38109
38899
  editorOk = true;
@@ -38122,12 +38912,13 @@ function LogInkApp(deps) {
38122
38912
  try {
38123
38913
  const content = readFileSync$1(file, 'utf8');
38124
38914
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
38125
- dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
38915
+ dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
38126
38916
  }
38127
38917
  catch (error) {
38128
38918
  dispatch({
38129
38919
  type: 'setStatus',
38130
38920
  value: `Failed to read back edited draft: ${error.message}`,
38921
+ kind: 'error',
38131
38922
  });
38132
38923
  }
38133
38924
  }
@@ -38203,7 +38994,7 @@ function LogInkApp(deps) {
38203
38994
  const applyCommitSplit = React.useCallback(async () => {
38204
38995
  const splitPlan = state.splitPlan;
38205
38996
  if (!splitPlan?.plan || !splitPlan.planContext) {
38206
- dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
38997
+ dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
38207
38998
  return;
38208
38999
  }
38209
39000
  // Diagnostic dump for the silent-failure bug surfaced in #944
@@ -39174,7 +39965,7 @@ function LogInkApp(deps) {
39174
39965
  };
39175
39966
  const handler = handlers[id];
39176
39967
  if (!handler) {
39177
- dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired` });
39968
+ dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
39178
39969
  return;
39179
39970
  }
39180
39971
  const result = await handler();
@@ -39219,7 +40010,37 @@ function LogInkApp(deps) {
39219
40010
  // without flickering the surfaces through a 'loading' phase.
39220
40011
  await refreshContext({ silent: true });
39221
40012
  }
39222
- }, [context, dispatch, git, refreshContext, refreshHistoryRows, state.branchSort, state.filter, state.selectedBranchIndex,
40013
+ // Stash workflow follow-up. Two distinct behaviours.
40014
+ //
40015
+ // **apply / pop**: the user brought stashed content back into the
40016
+ // worktree, but the sidebar still has them on the stash view.
40017
+ // Expected next move is "look at what landed in my worktree", so
40018
+ // jump them to history view (where the worktree counts in the
40019
+ // sidebar are visible) AND refresh worktree context explicitly so
40020
+ // the staged / unstaged / untracked numbers reflect the changes.
40021
+ //
40022
+ // **drop**: the silent context refresh above already re-fetched
40023
+ // the stash list, BUT users reported it feeling like nothing
40024
+ // happened. Fix two things: refresh worktree alongside (drops can
40025
+ // affect untracked files when the stash held `-u` state), and
40026
+ // surface the new stash count on the status line so there's
40027
+ // unambiguous feedback that the drop landed and the list shrank.
40028
+ if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
40029
+ dispatch({ type: 'pushView', value: 'history' });
40030
+ await refreshWorktreeContext();
40031
+ }
40032
+ if (result?.ok && id === 'drop-stash') {
40033
+ // Explicit worktree refresh in case the dropped stash carried
40034
+ // untracked-file state that's now collected.
40035
+ await refreshWorktreeContext();
40036
+ // The silent context refresh already replaced `context.stashes`;
40037
+ // reading the count back here would be stale because closures
40038
+ // capture the pre-refresh value. Status message stays generic
40039
+ // ("Dropped stash@{N}") — the visible list shrinking is the
40040
+ // unambiguous signal that the operation landed.
40041
+ }
40042
+ }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
40043
+ state.branchSort, state.filter, state.selectedBranchIndex,
39223
40044
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
39224
40045
  state.statusFilterMask, state.tagSort]);
39225
40046
  // Resolve the active view's "yank target" (commit hash / branch /
@@ -39381,15 +40202,15 @@ function LogInkApp(deps) {
39381
40202
  }
39382
40203
  }
39383
40204
  if (!value || !label) {
39384
- dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
40205
+ dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
39385
40206
  return;
39386
40207
  }
39387
40208
  try {
39388
40209
  await clipboard(value);
39389
- dispatch({ type: 'setStatus', value: `Copied ${label}` });
40210
+ dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
39390
40211
  }
39391
40212
  catch (error) {
39392
- dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}` });
40213
+ dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
39393
40214
  }
39394
40215
  }, [
39395
40216
  clipboardRunner,
@@ -39449,63 +40270,175 @@ function LogInkApp(deps) {
39449
40270
  React.useEffect(() => {
39450
40271
  loadingMoreCommitsRef.current = loadingMoreCommits;
39451
40272
  }, [loadingMoreCommits]);
40273
+ // STABLE useCallback (empty deps) for loadMoreCommits. The function
40274
+ // reads the volatile state (commit counts, fetch args, hasMore) via
40275
+ // refs that update on every render so the identity stays constant.
40276
+ //
40277
+ // Why stable matters: the cursor-syncs-history auto-load chain
40278
+ // calls this through a forward-reference ref (loadMoreCommitsRef).
40279
+ // If loadMoreCommits regenerated on every render — as the previous
40280
+ // implementation did via state deps — there was a render-order
40281
+ // race: the cursor sync effect would call the PREVIOUS render's
40282
+ // callback (still in the ref because the ref-setter useEffect runs
40283
+ // after the cursor-sync effect in declaration order), which had
40284
+ // captured a stale `state.commits.length` and re-fetched the same
40285
+ // window. The auto-load chain appeared to fire but never advanced
40286
+ // through history.
40287
+ //
40288
+ // Stable identity + refs sidesteps the race entirely: the function
40289
+ // never changes, and every call reads the latest state.
40290
+ const loadMoreStateRef = React.useRef({
40291
+ commitsLength: state.commits.length,
40292
+ filteredCommitsLength: state.filteredCommits.length,
40293
+ historyFetchArgs: state.historyFetchArgs,
40294
+ hasMoreCommits,
40295
+ logArgv,
40296
+ });
40297
+ loadMoreStateRef.current = {
40298
+ commitsLength: state.commits.length,
40299
+ filteredCommitsLength: state.filteredCommits.length,
40300
+ historyFetchArgs: state.historyFetchArgs,
40301
+ hasMoreCommits,
40302
+ logArgv,
40303
+ };
40304
+ const loadMoreCommits = React.useCallback(async (options = {}) => {
40305
+ const snap = loadMoreStateRef.current;
40306
+ if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
40307
+ return { fired: false, addedCommits: 0 };
40308
+ }
40309
+ if (snap.filteredCommitsLength === 0) {
40310
+ return { fired: false, addedCommits: 0 };
40311
+ }
40312
+ loadingMoreCommitsRef.current = true;
40313
+ const requestId = loadMoreRequestRef.current + 1;
40314
+ loadMoreRequestRef.current = requestId;
40315
+ setLoadingMoreCommits(true);
40316
+ dispatch({
40317
+ type: 'setStatus',
40318
+ value: options.statusMessage || 'loading older commits',
40319
+ loading: true,
40320
+ });
40321
+ const fetchArgs = snap.historyFetchArgs;
40322
+ const mergedArgv = {
40323
+ ...snap.logArgv,
40324
+ ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
40325
+ ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
40326
+ };
40327
+ // Load-more paths a fresh page from git AFTER what's already
40328
+ // loaded; pass the stash hashes again so the additional rows
40329
+ // stay graph-consistent with the boot fetch (a window that
40330
+ // dropped stashes mid-stream would render with broken junctions).
40331
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40332
+ const nextRows = await safe(getLogRows(git, mergedArgv, {
40333
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40334
+ skip: snap.commitsLength,
40335
+ extraRefs: stashHashes,
40336
+ }));
40337
+ if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
40338
+ return { fired: false, addedCommits: 0 };
40339
+ }
40340
+ loadingMoreCommitsRef.current = false;
40341
+ setLoadingMoreCommits(false);
40342
+ const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
40343
+ if (!nextRows) {
40344
+ dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
40345
+ return { fired: false, addedCommits: 0 };
40346
+ }
40347
+ if (nextRows?.length) {
40348
+ dispatch({ type: 'appendRows', rows: nextRows });
40349
+ }
40350
+ setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40351
+ return { fired: true, addedCommits: nextCommitCount };
40352
+ // Empty deps — the function is intentionally stable. State is
40353
+ // read via `loadMoreStateRef.current` at call time, and `dispatch`
40354
+ // / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
40355
+ // already stable across renders by React's contract.
40356
+ }, [dispatch, git]);
40357
+ // Scroll-near-bottom auto-trigger. Fires when the user's cursor is
40358
+ // within 20 rows of the last loaded commit so older history is
40359
+ // already on its way by the time they reach the bottom.
39452
40360
  React.useEffect(() => {
39453
40361
  const remaining = state.filteredCommits.length - state.selectedIndex - 1;
39454
- async function loadMoreCommits() {
39455
- if (!logArgv || logArgv.limit || loadingMoreCommitsRef.current || !hasMoreCommits) {
39456
- return;
39457
- }
39458
- if (state.filteredCommits.length === 0 || remaining > 20) {
39459
- return;
39460
- }
39461
- loadingMoreCommitsRef.current = true;
39462
- const requestId = loadMoreRequestRef.current + 1;
39463
- loadMoreRequestRef.current = requestId;
39464
- setLoadingMoreCommits(true);
39465
- dispatch({ type: 'setStatus', value: 'loading older commits' });
39466
- const fetchArgs = state.historyFetchArgs;
39467
- const mergedArgv = {
39468
- ...logArgv,
39469
- ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
39470
- ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
39471
- };
39472
- const nextRows = await safe(getLogRows(git, mergedArgv, {
39473
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
39474
- skip: state.commits.length,
39475
- }));
39476
- if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
39477
- return;
39478
- }
39479
- loadingMoreCommitsRef.current = false;
39480
- setLoadingMoreCommits(false);
39481
- const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
39482
- if (!nextRows) {
39483
- dispatch({ type: 'setStatus', value: 'failed to load older commits' });
39484
- return;
39485
- }
39486
- if (nextRows?.length) {
39487
- dispatch({ type: 'appendRows', rows: nextRows });
40362
+ if (remaining > 20)
40363
+ return;
40364
+ void loadMoreCommits().then((result) => {
40365
+ if (result.fired) {
40366
+ dispatch({
40367
+ type: 'setStatus',
40368
+ value: result.addedCommits
40369
+ ? `loaded ${result.addedCommits} older commits`
40370
+ : 'end of history',
40371
+ });
39488
40372
  }
39489
- setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
39490
- dispatch({
39491
- type: 'setStatus',
39492
- value: nextCommitCount
39493
- ? `loaded ${nextCommitCount} older commits`
39494
- : 'end of history',
39495
- });
39496
- }
39497
- void loadMoreCommits();
40373
+ });
39498
40374
  }, [
39499
40375
  dispatch,
39500
- git,
39501
- hasMoreCommits,
39502
- loadingMoreCommits,
39503
- logArgv,
39504
- state.commits.length,
40376
+ loadMoreCommits,
39505
40377
  state.filteredCommits.length,
39506
- state.historyFetchArgs,
39507
40378
  state.selectedIndex,
39508
40379
  ]);
40380
+ /**
40381
+ * Targeted-context loader for the cursor-syncs-history effect. Called
40382
+ * when the resolver returns `load-context` — the user cursored a
40383
+ * branch / tag / stash whose target commit isn't in the loaded
40384
+ * window, so we run a `git log` anchored on that commit (guaranteed
40385
+ * to include it) and merge the result via `appendRows` (which
40386
+ * already deduplicates by hash).
40387
+ *
40388
+ * Stable identity (empty deps) for the same reason as
40389
+ * `loadMoreCommits` — the cursor-sync effect calls this through a
40390
+ * forward-reference ref, and a regenerating callback would
40391
+ * reintroduce the render-order race that bit the previous chain.
40392
+ * All volatile state (logArgv, mostly) is read via refs.
40393
+ */
40394
+ const loadCommitContextStateRef = React.useRef({ logArgv });
40395
+ loadCommitContextStateRef.current = { logArgv };
40396
+ const loadCommitContext = React.useCallback(async (target) => {
40397
+ const snap = loadCommitContextStateRef.current;
40398
+ if (!snap.logArgv)
40399
+ return;
40400
+ dispatch({
40401
+ type: 'setStatus',
40402
+ value: `Loading commits around ${target.label}…`,
40403
+ loading: true,
40404
+ });
40405
+ try {
40406
+ // No stashHashes here — `getLogRowsAnchoredOn` walks only from
40407
+ // the target so it can guarantee the target's inclusion.
40408
+ // Stashes are already in the loaded graph from boot's
40409
+ // `loadRowsWithStashes`; `appendRows` deduplicates by hash so
40410
+ // the merged result keeps both views without double-counting.
40411
+ const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
40412
+ if (!mountedRef.current)
40413
+ return;
40414
+ if (rows.length > 0) {
40415
+ dispatch({ type: 'appendRows', rows });
40416
+ // Don't dispatch a setStatus here — the cursor-sync effect
40417
+ // will re-fire on the appendRows-driven filteredCommits
40418
+ // change and either jump (success) or report unreachable
40419
+ // (failure), surfacing the right message.
40420
+ }
40421
+ else {
40422
+ dispatch({
40423
+ type: 'setStatus',
40424
+ value: `${target.label} target commit returned no rows — orphan ref?`,
40425
+ kind: 'warning',
40426
+ });
40427
+ }
40428
+ }
40429
+ catch (error) {
40430
+ if (mountedRef.current) {
40431
+ dispatch({
40432
+ type: 'setStatus',
40433
+ value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
40434
+ kind: 'error',
40435
+ });
40436
+ }
40437
+ }
40438
+ }, [dispatch, git]);
40439
+ React.useEffect(() => {
40440
+ loadCommitContextRef.current = loadCommitContext;
40441
+ }, [loadCommitContext]);
39509
40442
  // Server-side history filter (#776). When the user submits `path:foo`
39510
40443
  // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
39511
40444
  // this effect picks up the change, re-runs `getLogRows` with merged
@@ -39541,12 +40474,16 @@ function LogInkApp(deps) {
39541
40474
  value: description ? `Refetching with ${description}` : 'Restoring full log',
39542
40475
  });
39543
40476
  void (async () => {
39544
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40477
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40478
+ const nextRows = await safe(getLogRows(git, merged, {
40479
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40480
+ extraRefs: stashHashes,
40481
+ }));
39545
40482
  if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
39546
40483
  return;
39547
40484
  }
39548
40485
  if (!nextRows) {
39549
- dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
40486
+ dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
39550
40487
  return;
39551
40488
  }
39552
40489
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39557,6 +40494,7 @@ function LogInkApp(deps) {
39557
40494
  value: description
39558
40495
  ? `Showing ${matched} commits matching ${description}`
39559
40496
  : 'Showing full log',
40497
+ kind: 'success',
39560
40498
  });
39561
40499
  })();
39562
40500
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
@@ -39586,12 +40524,20 @@ function LogInkApp(deps) {
39586
40524
  : 'Loading compact history…',
39587
40525
  });
39588
40526
  void (async () => {
39589
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40527
+ // Include stash commits as graph roots so the toggle's re-fetch
40528
+ // sees the same rich graph the boot loader assembles. Without
40529
+ // this, flipping `\` into full mode and back loses the stash
40530
+ // anchors that loadRowsWithStashes seeded on boot.
40531
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40532
+ const nextRows = await safe(getLogRows(git, merged, {
40533
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40534
+ extraRefs: stashHashes,
40535
+ }));
39590
40536
  if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
39591
40537
  return;
39592
40538
  }
39593
40539
  if (!nextRows) {
39594
- dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
40540
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
39595
40541
  return;
39596
40542
  }
39597
40543
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39602,6 +40548,7 @@ function LogInkApp(deps) {
39602
40548
  value: state.fullGraph
39603
40549
  ? `Showing ${matched} commits across all branches`
39604
40550
  : `Showing ${matched} commits (compact)`,
40551
+ kind: 'success',
39605
40552
  });
39606
40553
  })();
39607
40554
  }, [dispatch, git, logArgv, state.fullGraph]);
@@ -40296,6 +41243,17 @@ function createLogArgvFromUiArgv(argv) {
40296
41243
  return {
40297
41244
  $0: argv.$0,
40298
41245
  _: ['log'],
41246
+ // Pass `--all` through from the CLI. The yargs default is `true`
41247
+ // since 0.54.x — user feedback consistently asked for the
41248
+ // GitKraken-style "see all branches, tags, stashes" view as the
41249
+ // starting state. `coco ui --no-all` opts back to
41250
+ // current-branch-only.
41251
+ //
41252
+ // Note: passing `--branch foo` does NOT automatically scope away
41253
+ // from --all. If the user wants strictly that branch, they pass
41254
+ // `coco ui --branch foo --no-all`. We considered the implicit
41255
+ // scope-narrowing but it surprises users who pass `--branch` as
41256
+ // a "highlight this branch in the all-refs view" hint.
40299
41257
  all: argv.all,
40300
41258
  branch: argv.branch,
40301
41259
  format: 'table',
@@ -40332,6 +41290,26 @@ function withCacheWrite(repoPath, loader) {
40332
41290
  return rows;
40333
41291
  };
40334
41292
  }
41293
+ /**
41294
+ * Workstation-aware log loader (#1034 follow-up). Calls `git stash
41295
+ * list` first to collect every stash's commit hash, then passes them
41296
+ * as extra refs to `getLogRows` so the graph includes every stash as
41297
+ * a node — not just the latest (which is the only one `refs/stash`
41298
+ * points at and the only one `git log --all` walks).
41299
+ *
41300
+ * Without this, the stash → history cursor sync added in #1034 only
41301
+ * worked for `stash@{0}`; cursoring any older stash row reported
41302
+ * "tip not in loaded window" because that stash's commit hash was
41303
+ * never in the loaded graph window in the first place.
41304
+ *
41305
+ * The extra git call is cheap (one `git stash list --format=%H`,
41306
+ * usually sub-50ms). It's only an additive cost when stashes exist;
41307
+ * users on stash-free repos pay nothing.
41308
+ */
41309
+ async function loadRowsWithStashes(git, logArgv) {
41310
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
41311
+ return getLogRows(git, logArgv, { extraRefs: stashHashes });
41312
+ }
40335
41313
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
40336
41314
  const config = options.config || loadConfig(logArgv);
40337
41315
  const git = options.git || getRepo();
@@ -40350,7 +41328,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
40350
41328
  const initialRows = options.rows || cachedRows || [];
40351
41329
  const loadRows = options.rows
40352
41330
  ? undefined
40353
- : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
41331
+ : withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
40354
41332
  await startInkInteractiveLog(git, initialRows, {}, {
40355
41333
  appLabel: 'coco',
40356
41334
  idleTips: config.logTui?.idleTips,
@@ -40378,7 +41356,7 @@ async function startCocoUi(argv) {
40378
41356
  idleTips: config.logTui?.idleTips,
40379
41357
  dateBucketing: config.logTui?.dateBucketing,
40380
41358
  initialView: argv.view || 'history',
40381
- loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
41359
+ loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
40382
41360
  logArgv,
40383
41361
  theme: createUiTheme(config, argv),
40384
41362
  });
@@ -41581,9 +42559,9 @@ const options = {
41581
42559
  default: 'history',
41582
42560
  },
41583
42561
  all: {
41584
- description: 'Load commits from all local and remote refs in history mode',
42562
+ 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.',
41585
42563
  type: 'boolean',
41586
- default: false,
42564
+ default: true,
41587
42565
  },
41588
42566
  branch: {
41589
42567
  description: 'Load history reachable from a branch or ref',