git-coco 0.54.0 → 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 +1778 -563
  2. package/dist/index.js +1778 -563
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.54.0";
81
+ const BUILD_VERSION = "0.55.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -18368,7 +18368,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
18368
18368
  const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
18369
18369
  const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
18370
18370
  const LOG_DEFAULT_LIMIT = 30;
18371
- const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
18371
+ // Bumped from 300 → 1000 in 0.54.2. With the full-graph default
18372
+ // (#1034) the workstation surfaces many more refs (all branches, all
18373
+ // tags, plus stash commits added via `extraRefs`), and on active repos
18374
+ // the 300-commit cap was cutting off year+-old stash bases and old
18375
+ // tag commits — making the cursor-syncs-history effect report "tip
18376
+ // not in loaded window" instead of moving the graph cursor. 1000
18377
+ // fits a year of activity for most repos, git log is still sub-200ms,
18378
+ // and Ink virtualises scroll so render cost stays flat.
18379
+ const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
18372
18380
  function toArray(value) {
18373
18381
  if (!value) {
18374
18382
  return [];
@@ -18548,12 +18556,77 @@ function buildLogArgs(argv, options = {}) {
18548
18556
  else if (argv.branch) {
18549
18557
  args.push(argv.branch);
18550
18558
  }
18559
+ // Extra refs (stash commits etc.) — append after the --all / branch
18560
+ // selector but BEFORE the path separator. Git treats them as
18561
+ // additional graph roots, so the traversal includes them alongside
18562
+ // whatever --all / --branch already covers.
18563
+ if (options.extraRefs && options.extraRefs.length > 0) {
18564
+ args.push(...options.extraRefs);
18565
+ }
18551
18566
  const paths = toArray(argv.path);
18552
18567
  if (paths.length > 0) {
18553
18568
  args.push('--', ...paths);
18554
18569
  }
18555
18570
  return args;
18556
18571
  }
18572
+ /**
18573
+ * Default size of a targeted-context window. Sized to comfortably
18574
+ * cover a year of activity on most repos so the cursor-sync's
18575
+ * "jump to commit anchored on a ref I just selected" can succeed
18576
+ * without paginating through the whole history.
18577
+ */
18578
+ const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
18579
+ /**
18580
+ * Load a window of commits anchored on a specific hash. Used by the
18581
+ * cursor-sync effect when the user selects a ref (branch / tag /
18582
+ * stash) whose target commit isn't in the loaded graph window.
18583
+ *
18584
+ * Critical detail: this walks **only from the target** (and its
18585
+ * ancestors), NOT from `--all`. Why: when you combine `--all` with
18586
+ * `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
18587
+ * the result by date, and slices the newest N rows. If the target
18588
+ * is older than the Nth newest commit across all refs (very common
18589
+ * for year-old tags / branches on active repos), it falls off the
18590
+ * slice even though it was passed as a root. Walking from the
18591
+ * target alone guarantees the target IS the first row of the
18592
+ * output and its ancestors fill the rest.
18593
+ *
18594
+ * The caller merges the result via the `appendRows` reducer action
18595
+ * which deduplicates by hash, so the target's ancestry slots into
18596
+ * the existing `--all` graph cleanly. The user's loaded view ends
18597
+ * up as the union of: the original `--all` window + target's
18598
+ * ancestry — exactly what's needed for the cursor to land.
18599
+ *
18600
+ * Capped at `options.limit` (default 5000) to keep one targeted
18601
+ * fetch bounded. For most refs, even a 100-commit limit would be
18602
+ * enough to surface the target; we go higher to also pull in the
18603
+ * surrounding context so the user can scroll around the landed
18604
+ * cursor.
18605
+ */
18606
+ async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
18607
+ // Strip every "walk many refs" toggle so buildLogArgs produces a
18608
+ // clean `git log <flags> <targetHash>` — exactly the walk that
18609
+ // guarantees the target's inclusion.
18610
+ const merged = {
18611
+ ...argv,
18612
+ all: false,
18613
+ view: 'compact', // suppresses 'full' → '--all' mapping
18614
+ branch: undefined,
18615
+ path: undefined,
18616
+ };
18617
+ // Also drop --first-parent / --no-merges so the target's ancestry
18618
+ // renders with full topology (matters for stash commits which are
18619
+ // merges by construction).
18620
+ const baseArgs = buildLogArgs(merged, {
18621
+ limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
18622
+ }).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
18623
+ // Splice the target as the positional ref. `buildLogArgs` already
18624
+ // appended any `--all`/`--branch`/`<extraRefs>` it considered;
18625
+ // since we cleared all those above, the only positional ref we
18626
+ // add is the target.
18627
+ baseArgs.push(targetHash);
18628
+ return parseLogOutput(await git.raw(baseArgs));
18629
+ }
18557
18630
  /**
18558
18631
  * Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
18559
18632
  *
@@ -18661,6 +18734,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18661
18734
  };
18662
18735
  }
18663
18736
 
18737
+ function parseStashSubject(subject) {
18738
+ const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18739
+ if (!match) {
18740
+ return {
18741
+ branch: '<unknown>',
18742
+ message: subject,
18743
+ };
18744
+ }
18745
+ return {
18746
+ branch: match[1],
18747
+ message: match[2] || subject,
18748
+ };
18749
+ }
18750
+ function parseStashList(output) {
18751
+ return output
18752
+ .split('\n')
18753
+ .map((line) => line.trim())
18754
+ .filter(Boolean)
18755
+ .map((line) => {
18756
+ const [ref, hash, parents, date, subject] = line.split('\x1f');
18757
+ const parsedSubject = parseStashSubject(subject || '');
18758
+ // `%P` returns space-separated parent hashes. Stash commits are
18759
+ // merges with 2-3 parents; the FIRST is the base (HEAD at stash
18760
+ // time). Empty parents string (legacy / corrupted entries) maps
18761
+ // to an empty baseHash; the cursor-sync caller treats that as
18762
+ // "no base available, fall back to stash hash."
18763
+ const baseHash = parents ? (parents.split(' ')[0] || '') : '';
18764
+ return {
18765
+ ref,
18766
+ hash,
18767
+ baseHash,
18768
+ date,
18769
+ branch: parsedSubject.branch,
18770
+ message: parsedSubject.message,
18771
+ };
18772
+ });
18773
+ }
18774
+ function parseStashFiles(output) {
18775
+ return output
18776
+ .split('\n')
18777
+ .map((line) => line.trim())
18778
+ .filter(Boolean);
18779
+ }
18780
+ /**
18781
+ * Resolve the commit hashes for every stash, in `stash@{N}` order.
18782
+ *
18783
+ * Used by the workstation's history loader to include older stashes
18784
+ * as graph roots — `git log --all` only walks `refs/stash` (the
18785
+ * latest stash) by default, so stash@{1+} commits live off-graph
18786
+ * unless explicitly referenced. Passing this list as positional refs
18787
+ * to `git log` makes every stash appear as a graph node, which lets
18788
+ * the cursor-syncs-history effect actually land on them when the
18789
+ * user navigates the stashes sidebar.
18790
+ *
18791
+ * Cheap: one `git stash list` call, no per-stash fan-out. Returns
18792
+ * an empty array when there are no stashes — callers can pass the
18793
+ * result through unconditionally.
18794
+ */
18795
+ async function getStashCommitHashes(git) {
18796
+ const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
18797
+ return raw
18798
+ .split('\n')
18799
+ .map((line) => line.trim())
18800
+ .filter(Boolean);
18801
+ }
18802
+ async function getStashOverview(git) {
18803
+ // Format fields (separated by 0x1f / unit separator):
18804
+ // %gd — stash reflog selector (stash@{N})
18805
+ // %H — stash commit hash
18806
+ // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18807
+ // %ci — committer date, ISO format
18808
+ // %gs — reflog subject ("WIP on main: <subject>")
18809
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
18810
+ return {
18811
+ stashes: await Promise.all(stashes.map(async (stash) => ({
18812
+ ...stash,
18813
+ files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18814
+ }))),
18815
+ };
18816
+ }
18817
+ /**
18818
+ * Full unified-patch diff for a stash. Used by the diff surface when
18819
+ * `state.diffSource === 'stash'` to render the stash's changes inline.
18820
+ *
18821
+ * Empty stashes (e.g. created by `git stash --keep-index` against an
18822
+ * already-clean tree) return [] rather than throwing — surfaces fall
18823
+ * back to a "no diff to display" message.
18824
+ */
18825
+ async function getStashDiff(git, stashRef) {
18826
+ return (await git.raw(['stash', 'show', '-p', stashRef]))
18827
+ .split('\n')
18828
+ .map((line) => line.replace(/\r$/, ''));
18829
+ }
18830
+ /**
18831
+ * Slice a unified-patch into per-file sections. Each entry records the
18832
+ * file path and the offset of its `diff --git` header within `lines`.
18833
+ * Used by the stash explorer to build a per-file cursor + cherry-pick
18834
+ * the file at the cursor.
18835
+ *
18836
+ * Renames / moves return the destination path (the `b/` side); the
18837
+ * action surface treats that as the path to materialize from the stash.
18838
+ *
18839
+ * Path quoting: git wraps paths containing spaces or special characters
18840
+ * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18841
+ * The parser handles both the unquoted and quoted forms; without that,
18842
+ * stash-file navigation and cherry-pick silently broke for any file
18843
+ * whose path contained a space.
18844
+ */
18845
+ function parseStashDiffFiles(lines) {
18846
+ const files = [];
18847
+ for (let i = 0; i < lines.length; i += 1) {
18848
+ const line = lines[i];
18849
+ const parsed = parseDiffGitHeader(line);
18850
+ if (parsed) {
18851
+ files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18852
+ }
18853
+ }
18854
+ return files;
18855
+ }
18856
+ /**
18857
+ * Resolve which stash file *contains* a given line offset — the user's
18858
+ * cursor scrolls through a concatenated multi-file patch, and this is
18859
+ * what powers the "File N/M: <path>" panel header, the inline header
18860
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18861
+ * dispatchers' "what file is the cursor on" lookup.
18862
+ *
18863
+ * Returns `undefined` when the file list is empty *or* the offset
18864
+ * lands before the very first file's `diff --git` header (e.g. when
18865
+ * `--stat` summary lines lead the patch). Callers fall through to a
18866
+ * "no file selected" state in that case.
18867
+ */
18868
+ function findStashFileForOffset(files, offset) {
18869
+ if (files.length === 0)
18870
+ return undefined;
18871
+ let current;
18872
+ for (const file of files) {
18873
+ if (file.startLine <= offset) {
18874
+ current = file;
18875
+ }
18876
+ else {
18877
+ break;
18878
+ }
18879
+ }
18880
+ // First file is the canonical fallback — even if the offset lands
18881
+ // before its header (rare), we want the cursor to be "in" something
18882
+ // so the user's actions have a target.
18883
+ return current ?? files[0];
18884
+ }
18885
+ const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18886
+ function parseDiffGitHeader(line) {
18887
+ const match = line.match(DIFF_GIT_HEADER);
18888
+ if (!match)
18889
+ return undefined;
18890
+ const aPath = unescapeGitQuoted(match[1]) || match[2];
18891
+ const bPath = unescapeGitQuoted(match[3]) || match[4];
18892
+ if (!aPath || !bPath)
18893
+ return undefined;
18894
+ return { aPath, bPath };
18895
+ }
18896
+ function unescapeGitQuoted(value) {
18897
+ if (value === undefined)
18898
+ return undefined;
18899
+ // Git's diff header quoting escapes `"`, `\`, and the usual
18900
+ // C-style sequences. Reverse the most common ones so callers get the
18901
+ // raw on-disk path.
18902
+ return value
18903
+ .replace(/\\\\/g, '\\')
18904
+ .replace(/\\"/g, '"')
18905
+ .replace(/\\t/g, '\t')
18906
+ .replace(/\\n/g, '\n');
18907
+ }
18908
+
18664
18909
  const FIELD_SEPARATOR$2 = '\x1f';
18665
18910
  function parseBranchRefs(output) {
18666
18911
  return output
@@ -18853,143 +19098,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18853
19098
  }
18854
19099
  }
18855
19100
 
18856
- function parseStashSubject(subject) {
18857
- const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18858
- if (!match) {
18859
- return {
18860
- branch: '<unknown>',
18861
- message: subject,
18862
- };
18863
- }
18864
- return {
18865
- branch: match[1],
18866
- message: match[2] || subject,
18867
- };
18868
- }
18869
- function parseStashList(output) {
18870
- return output
18871
- .split('\n')
18872
- .map((line) => line.trim())
18873
- .filter(Boolean)
18874
- .map((line) => {
18875
- const [ref, hash, date, subject] = line.split('\x1f');
18876
- const parsedSubject = parseStashSubject(subject || '');
18877
- return {
18878
- ref,
18879
- hash,
18880
- date,
18881
- branch: parsedSubject.branch,
18882
- message: parsedSubject.message,
18883
- };
18884
- });
18885
- }
18886
- function parseStashFiles(output) {
18887
- return output
18888
- .split('\n')
18889
- .map((line) => line.trim())
18890
- .filter(Boolean);
18891
- }
18892
- async function getStashOverview(git) {
18893
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
18894
- return {
18895
- stashes: await Promise.all(stashes.map(async (stash) => ({
18896
- ...stash,
18897
- files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18898
- }))),
18899
- };
18900
- }
18901
- /**
18902
- * Full unified-patch diff for a stash. Used by the diff surface when
18903
- * `state.diffSource === 'stash'` to render the stash's changes inline.
18904
- *
18905
- * Empty stashes (e.g. created by `git stash --keep-index` against an
18906
- * already-clean tree) return [] rather than throwing — surfaces fall
18907
- * back to a "no diff to display" message.
18908
- */
18909
- async function getStashDiff(git, stashRef) {
18910
- return (await git.raw(['stash', 'show', '-p', stashRef]))
18911
- .split('\n')
18912
- .map((line) => line.replace(/\r$/, ''));
18913
- }
18914
- /**
18915
- * Slice a unified-patch into per-file sections. Each entry records the
18916
- * file path and the offset of its `diff --git` header within `lines`.
18917
- * Used by the stash explorer to build a per-file cursor + cherry-pick
18918
- * the file at the cursor.
18919
- *
18920
- * Renames / moves return the destination path (the `b/` side); the
18921
- * action surface treats that as the path to materialize from the stash.
18922
- *
18923
- * Path quoting: git wraps paths containing spaces or special characters
18924
- * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18925
- * The parser handles both the unquoted and quoted forms; without that,
18926
- * stash-file navigation and cherry-pick silently broke for any file
18927
- * whose path contained a space.
18928
- */
18929
- function parseStashDiffFiles(lines) {
18930
- const files = [];
18931
- for (let i = 0; i < lines.length; i += 1) {
18932
- const line = lines[i];
18933
- const parsed = parseDiffGitHeader(line);
18934
- if (parsed) {
18935
- files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18936
- }
18937
- }
18938
- return files;
18939
- }
18940
- /**
18941
- * Resolve which stash file *contains* a given line offset — the user's
18942
- * cursor scrolls through a concatenated multi-file patch, and this is
18943
- * what powers the "File N/M: <path>" panel header, the inline header
18944
- * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18945
- * dispatchers' "what file is the cursor on" lookup.
18946
- *
18947
- * Returns `undefined` when the file list is empty *or* the offset
18948
- * lands before the very first file's `diff --git` header (e.g. when
18949
- * `--stat` summary lines lead the patch). Callers fall through to a
18950
- * "no file selected" state in that case.
18951
- */
18952
- function findStashFileForOffset(files, offset) {
18953
- if (files.length === 0)
18954
- return undefined;
18955
- let current;
18956
- for (const file of files) {
18957
- if (file.startLine <= offset) {
18958
- current = file;
18959
- }
18960
- else {
18961
- break;
18962
- }
18963
- }
18964
- // First file is the canonical fallback — even if the offset lands
18965
- // before its header (rare), we want the cursor to be "in" something
18966
- // so the user's actions have a target.
18967
- return current ?? files[0];
18968
- }
18969
- const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18970
- function parseDiffGitHeader(line) {
18971
- const match = line.match(DIFF_GIT_HEADER);
18972
- if (!match)
18973
- return undefined;
18974
- const aPath = unescapeGitQuoted(match[1]) || match[2];
18975
- const bPath = unescapeGitQuoted(match[3]) || match[4];
18976
- if (!aPath || !bPath)
18977
- return undefined;
18978
- return { aPath, bPath };
18979
- }
18980
- function unescapeGitQuoted(value) {
18981
- if (value === undefined)
18982
- return undefined;
18983
- // Git's diff header quoting escapes `"`, `\`, and the usual
18984
- // C-style sequences. Reverse the most common ones so callers get the
18985
- // raw on-disk path.
18986
- return value
18987
- .replace(/\\\\/g, '\\')
18988
- .replace(/\\"/g, '"')
18989
- .replace(/\\t/g, '\t')
18990
- .replace(/\\n/g, '\n');
18991
- }
18992
-
18993
19101
  function fileState(indexStatus, worktreeStatus) {
18994
19102
  if (indexStatus === '?' && worktreeStatus === '?') {
18995
19103
  return 'untracked';
@@ -19056,7 +19164,18 @@ function parseTagRefs(output) {
19056
19164
  .map((line) => line.trimEnd())
19057
19165
  .filter(Boolean)
19058
19166
  .map((line) => {
19059
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
19167
+ const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
19168
+ // For annotated tags `%(objectname:short)` returns the TAG
19169
+ // OBJECT's SHA, not the commit it points to — that's the SHA
19170
+ // sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
19171
+ // dereferences the tag and yields the commit's SHA, but is
19172
+ // EMPTY for lightweight tags (which are already direct
19173
+ // pointers to commits). Prefer the dereferenced form when
19174
+ // present, fall back to the object SHA otherwise. This is what
19175
+ // lets cursor-sync find the tagged commit in the loaded log
19176
+ // window — anchoring on the tag object's own SHA would never
19177
+ // match a commit row.
19178
+ const hash = derefedHash || objectHash;
19060
19179
  return {
19061
19180
  name,
19062
19181
  hash,
@@ -19068,7 +19187,7 @@ function parseTagRefs(output) {
19068
19187
  async function getTagOverview(git) {
19069
19188
  const output = await git.raw([
19070
19189
  'for-each-ref',
19071
- `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19190
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19072
19191
  '--sort=-creatordate',
19073
19192
  'refs/tags',
19074
19193
  ]);
@@ -19950,6 +20069,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
19950
20069
  output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
19951
20070
  }
19952
20071
 
20072
+ /**
20073
+ * Shared hash-matching helpers for cross-command lookups.
20074
+ *
20075
+ * Git surfaces the same commit with different short-hash lengths
20076
+ * depending on which command produced the row:
20077
+ *
20078
+ * - `for-each-ref --format=%(objectname:short)` (branches, tags,
20079
+ * stashes) honors `core.abbrev`, typically 7 chars.
20080
+ * - `git log --pretty=format:%h` (history rows) honors the same
20081
+ * setting BUT git auto-extends abbreviations to keep them unique
20082
+ * within the walked set — so the same commit can come back as 7
20083
+ * chars from one command and 8 (or more) from another.
20084
+ *
20085
+ * Consequence: any exact-equality lookup that compares a hash from
20086
+ * `for-each-ref` against a hash from `git log` will miss the match
20087
+ * even when both refer to the same commit. This bit the workstation's
20088
+ * cursor-sync effect twice during 0.54.2 — once in the resolver, once
20089
+ * in the `selectCommitByHash` reducer — and shows up wherever a ref
20090
+ * hash is checked against the loaded log window.
20091
+ *
20092
+ * The fix is bidirectional prefix matching: a hash matches another if
20093
+ * one is a prefix of the other. Below a 4-char floor we refuse to
20094
+ * match — three chars would collide with too many real commits.
20095
+ *
20096
+ * This module is the canonical place for that logic. Import it
20097
+ * anywhere you compare a "hash from one git formatter" against a
20098
+ * "hash from a different git formatter."
20099
+ *
20100
+ * Lives in `src/git/` because both `workstation/` and `commands/log/`
20101
+ * depend on it — `commands/log/` must not depend on `workstation/`,
20102
+ * so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
20103
+ */
20104
+ /**
20105
+ * Minimum length below which we refuse to prefix-match. Three chars
20106
+ * is too small to be a meaningful unique prefix for any real-world
20107
+ * git history.
20108
+ */
20109
+ const MIN_PREFIX_LENGTH = 4;
20110
+ /**
20111
+ * True when `a` and `b` refer to the same commit, tolerating
20112
+ * short-hash length differences from different git formatters.
20113
+ *
20114
+ * Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
20115
+ * string equality wins immediately (the common path); otherwise we
20116
+ * test bidirectional `startsWith` and bail when either input is too
20117
+ * short to be a meaningful prefix.
20118
+ */
20119
+ function hashesMatch(a, b) {
20120
+ if (!a || !b)
20121
+ return false;
20122
+ if (a === b)
20123
+ return true;
20124
+ if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
20125
+ return false;
20126
+ return a.startsWith(b) || b.startsWith(a);
20127
+ }
20128
+ /**
20129
+ * True when `hash` matches any entry in `candidates`. Convenience
20130
+ * wrapper for the common "is this ref's hash in any of the row's
20131
+ * hash variants?" check.
20132
+ */
20133
+ function hashesMatchAny(hash, candidates) {
20134
+ if (!hash)
20135
+ return false;
20136
+ return candidates.some((candidate) => hashesMatch(hash, candidate));
20137
+ }
20138
+ /**
20139
+ * True when `hash` is present in the loaded set — exact match first
20140
+ * (the O(1) fast path), then bidirectional `startsWith` over the set
20141
+ * to cover the formatter mismatch.
20142
+ *
20143
+ * The set is small in practice (1k–5k entries) so O(N) iteration on
20144
+ * miss is fine.
20145
+ */
20146
+ function hashLoaded(hash, loaded) {
20147
+ if (loaded.has(hash))
20148
+ return true;
20149
+ if (hash.length < MIN_PREFIX_LENGTH)
20150
+ return false;
20151
+ for (const entry of loaded) {
20152
+ if (entry.startsWith(hash) || hash.startsWith(entry))
20153
+ return true;
20154
+ }
20155
+ return false;
20156
+ }
20157
+
19953
20158
  const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
19954
20159
  /**
19955
20160
  * Parse a single `.gitattributes` body into the LFS-tracked
@@ -20237,9 +20442,16 @@ function applyCommitComposeAction(state, action) {
20237
20442
  field: state.field === 'summary' ? 'body' : 'summary',
20238
20443
  };
20239
20444
  case 'setEditing':
20445
+ // Audit finding #12: defensively clear `streamingPreview` when
20446
+ // editing toggles off AND no draft is in flight. The current
20447
+ // input pipeline never triggers this combination, but the
20448
+ // reducer is the source of truth — if a future code path
20449
+ // toggles editing off mid-stream, the preview shouldn't linger
20450
+ // below an idle compose panel.
20240
20451
  return {
20241
20452
  ...state,
20242
20453
  editing: action.value,
20454
+ streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
20243
20455
  };
20244
20456
  case 'setLoading':
20245
20457
  // Clearing loading also clears any in-flight streaming preview;
@@ -20251,6 +20463,22 @@ function applyCommitComposeAction(state, action) {
20251
20463
  streamingPreview: action.value ? state.streamingPreview : undefined,
20252
20464
  };
20253
20465
  case 'setDraft':
20466
+ // Audit finding #7: if the user has typed content in summary or
20467
+ // body, the AI draft would silently clobber their work with no
20468
+ // undo. Route the result to `pendingAiDraft` instead and surface
20469
+ // a confirmation message; the user accepts with `R` (replace)
20470
+ // or dismisses with Esc. Empty fields = safe to replace as
20471
+ // before, since there's nothing to lose.
20472
+ if (state.summary.trim() || state.body.trim()) {
20473
+ return {
20474
+ ...state,
20475
+ loading: false,
20476
+ streamingPreview: undefined,
20477
+ pendingAiDraft: action.value,
20478
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20479
+ details: undefined,
20480
+ };
20481
+ }
20254
20482
  // No `message` here — the loader → filled fields are the confirmation
20255
20483
  // that the AI generated something. A lingering "AI draft ready for
20256
20484
  // editing" line in the panel reads as stale state. The runtime still
@@ -20265,6 +20493,7 @@ function applyCommitComposeAction(state, action) {
20265
20493
  message: undefined,
20266
20494
  details: undefined,
20267
20495
  streamingPreview: undefined,
20496
+ pendingAiDraft: undefined,
20268
20497
  };
20269
20498
  case 'setResult':
20270
20499
  return {
@@ -20284,6 +20513,46 @@ function applyCommitComposeAction(state, action) {
20284
20513
  ...state,
20285
20514
  streamingPreview: action.value,
20286
20515
  };
20516
+ case 'setPendingAiDraft':
20517
+ // Audit finding #7: route the AI draft here (instead of straight
20518
+ // to summary/body via `setDraft`) when the user has unsaved
20519
+ // typing the draft would clobber. The dispatcher does the
20520
+ // user-content check; this reducer just stashes the draft and
20521
+ // surfaces a message inviting the user to accept or dismiss.
20522
+ return {
20523
+ ...state,
20524
+ loading: false,
20525
+ streamingPreview: undefined,
20526
+ pendingAiDraft: action.value,
20527
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20528
+ details: undefined,
20529
+ };
20530
+ case 'acceptPendingAiDraft':
20531
+ // Swap the pending draft into the editable fields and clear it.
20532
+ // Mirrors `setDraft`'s field positioning (focus on summary,
20533
+ // editing on) so the user lands in the same place whether they
20534
+ // accepted immediately or after deliberation.
20535
+ if (!state.pendingAiDraft)
20536
+ return state;
20537
+ return {
20538
+ ...state,
20539
+ ...splitCommitDraft(state.pendingAiDraft),
20540
+ field: 'summary',
20541
+ editing: true,
20542
+ loading: false,
20543
+ message: undefined,
20544
+ details: undefined,
20545
+ streamingPreview: undefined,
20546
+ pendingAiDraft: undefined,
20547
+ };
20548
+ case 'dismissPendingAiDraft':
20549
+ // User chose to keep their typing; drop the AI draft.
20550
+ return {
20551
+ ...state,
20552
+ pendingAiDraft: undefined,
20553
+ message: undefined,
20554
+ details: undefined,
20555
+ };
20287
20556
  case 'reset':
20288
20557
  // Drop message/details too — the post-commit "Created commit ..."
20289
20558
  // notification is already on the runtime status line (footer); a
@@ -20471,6 +20740,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20471
20740
  // classify below.
20472
20741
  const stream = await chain.stream(variables, signal ? { signal } : undefined);
20473
20742
  let chunkCount = 0;
20743
+ let callbackFailureCount = 0;
20744
+ // Audit finding #13: cap consecutive callback failures so a
20745
+ // genuinely broken render handler can't tie up the LLM call
20746
+ // silently for the user's entire wait. Five strikes (out of an
20747
+ // expected ~50-500 chunks for a normal commit message) is enough
20748
+ // to ride out a transient blip but small enough to bail before
20749
+ // the user finishes waiting on a useless stream.
20750
+ const MAX_CALLBACK_FAILURES = 5;
20474
20751
  for await (const messageChunk of stream) {
20475
20752
  const text = coerceChunkText(messageChunk);
20476
20753
  if (!text)
@@ -20479,12 +20756,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20479
20756
  chunkCount += 1;
20480
20757
  try {
20481
20758
  onChunk({ text, accumulated });
20759
+ // Successful callback resets the consecutive-failure counter —
20760
+ // we only bail on a STREAK of failures, not on isolated ones.
20761
+ callbackFailureCount = 0;
20482
20762
  }
20483
20763
  catch (callbackError) {
20484
20764
  // Deliberately swallow callback errors so a bad render handler
20485
20765
  // can't tank the entire LLM call. Log at verbose so users with
20486
20766
  // verbose mode on can still see what happened.
20487
- logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20767
+ callbackFailureCount += 1;
20768
+ logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20769
+ if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
20770
+ logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
20771
+ throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
20772
+ }
20488
20773
  }
20489
20774
  }
20490
20775
  if (!accumulated) {
@@ -20518,15 +20803,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20518
20803
  }
20519
20804
  catch (error) {
20520
20805
  // Cancellation classifier (#881 phase 3). Three signals: an
20521
- // explicitly aborted user signal (post-throw check), the
20522
- // standard DOM `AbortError`, or a Node `AbortSignal` with
20523
- // `signal.aborted === true` while a chain-internal error
20524
- // propagates. Any of these means "user wanted out," not "the
20525
- // call failed." Wrap the raw error so callers can pattern-match
20526
- // on `LangChainCancelledError` and carry the partial accumulated
20527
- // text in case the caller wants to salvage anything.
20806
+ // explicitly aborted user signal (post-throw check) or a thrown
20807
+ // `AbortError` from the standard DOM API. Either means "user
20808
+ // wanted out," not "the call failed." Wrap the raw error so
20809
+ // callers can pattern-match on `LangChainCancelledError` and
20810
+ // carry the partial accumulated text in case the caller wants
20811
+ // to salvage anything.
20812
+ //
20813
+ // Audit finding #8: an earlier implementation also fell back to
20814
+ // `error.message.includes('aborted')` as a third signal. That
20815
+ // substring heuristic is footgun-shaped — legitimate provider
20816
+ // errors ("model not aborted properly", future API copy) would
20817
+ // misclassify as user cancels. Dropped; rely on the structured
20818
+ // signal (`signal.aborted`) and the standard error class
20819
+ // (`name === 'AbortError'`).
20528
20820
  const aborted = signal?.aborted ||
20529
- (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
20821
+ (error instanceof Error && error.name === 'AbortError');
20530
20822
  if (aborted) {
20531
20823
  throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20532
20824
  provider: effectiveProvider,
@@ -20785,6 +21077,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20785
21077
  // schema-validated retry — paying for a second LLM call only
20786
21078
  // on the edge case where the streamed output is unsalvageable.
20787
21079
  const streamingParser = createSchemaParser(schema, llm);
21080
+ // Capture the final accumulated text out-of-band so we can
21081
+ // attempt salvage if the parser throws on completion (audit
21082
+ // finding #1). Updated on every chunk; the last value is
21083
+ // whatever the stream produced before the parser ran. Empty
21084
+ // string when streaming throws before any chunks arrived.
21085
+ let streamedAccumulated = '';
20788
21086
  let salvaged;
20789
21087
  try {
20790
21088
  // `executeChainStreaming` runs the parser on the accumulated
@@ -20798,6 +21096,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20798
21096
  variables: budgetedPrompt.variables,
20799
21097
  parser: streamingParser,
20800
21098
  onChunk: ({ text, accumulated }) => {
21099
+ streamedAccumulated = accumulated;
20801
21100
  onStreamChunk(text, accumulated);
20802
21101
  },
20803
21102
  signal,
@@ -20827,13 +21126,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20827
21126
  cancelled: true,
20828
21127
  };
20829
21128
  }
20830
- // Streamed accumulated text didn't parse cleanly. Try the
20831
- // lossy salvager on whatever we have; if that produces a
20832
- // non-placeholder title, accept it. Otherwise fall through
20833
- // to the non-streaming path which can retry with a fresh
20834
- // LLM call.
20835
- logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20836
- salvaged = undefined;
21129
+ // Audit finding #1: try the lossy salvager on the accumulated
21130
+ // text before paying for a second LLM call. The salvager
21131
+ // strips code fences, attempts strict JSON parse, and falls
21132
+ // back to "first line is title, rest is body." We only accept
21133
+ // its output when it produced a real title — the placeholder
21134
+ // title ("Auto-generated commit") means the salvager
21135
+ // couldn't extract anything meaningful and the non-streaming
21136
+ // retry is the better choice.
21137
+ if (streamedAccumulated) {
21138
+ const candidate = salvageCommitMessageFromText(streamedAccumulated);
21139
+ if (candidate.title && candidate.title !== 'Auto-generated commit') {
21140
+ salvaged = candidate;
21141
+ logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
21142
+ }
21143
+ }
21144
+ if (!salvaged) {
21145
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
21146
+ }
20837
21147
  }
20838
21148
  // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20839
21149
  // see that across the catch. Re-init through the salvage path
@@ -20842,10 +21152,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20842
21152
  commitMsg = salvaged;
20843
21153
  }
20844
21154
  else if (!(commitMsg)) {
20845
- // Streaming threw; do the standard non-streaming flow to
20846
- // recover. This is the trade-off documented in the issue —
20847
- // streaming gives us a preview but the validated result still
20848
- // comes from the schema-aware retry path when streaming fails.
21155
+ // Streaming threw AND the salvager couldn't recover anything
21156
+ // useful; fall back to the standard non-streaming flow.
21157
+ // Documented trade-off from the issue: streaming gives us a
21158
+ // preview but the validated result still comes from the
21159
+ // schema-aware retry path when both streaming AND salvage
21160
+ // fail.
20849
21161
  commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20850
21162
  logger,
20851
21163
  tokenizer,
@@ -23535,7 +23847,16 @@ function createLogInkState(rows, options = {}) {
23535
23847
  worktreeDiffOffset: 0,
23536
23848
  filter: '',
23537
23849
  filterMode: false,
23538
- fullGraph: false,
23850
+ // Default to the full multi-ref graph (`git log --all`) so users
23851
+ // see how branches, tags, and stashes weave through the history
23852
+ // out of the box. Pre-0.54.x this defaulted to false (current
23853
+ // branch only); user feedback consistently asked for the
23854
+ // GitKraken-style "see everything" view as the starting state.
23855
+ // The `\` toggle still flips back to compact / current-branch
23856
+ // mode for users who want the cleaner single-line graph. Tests
23857
+ // override via `options.fullGraph` when they need the compact
23858
+ // case explicitly.
23859
+ fullGraph: options.fullGraph ?? true,
23539
23860
  showHelp: false,
23540
23861
  helpScrollOffset: 0,
23541
23862
  showCommandPalette: false,
@@ -23644,8 +23965,17 @@ function applyLogInkAction(state, action) {
23644
23965
  // branch's tip without the user manually scrolling. No-op when
23645
23966
  // the hash isn't in the loaded list (the runtime surfaces a
23646
23967
  // status hint in that case).
23968
+ //
23969
+ // Uses the shared `hashesMatchAny` helper to cover the
23970
+ // short-hash auto-extension mismatch between
23971
+ // `for-each-ref --format=%(objectname:short)` (cursored ref)
23972
+ // and `git log --pretty=format:%h` (history row). Without that
23973
+ // tolerance the resolver could decide "jump" but this reducer
23974
+ // would silently no-op — the status updates but the cursor
23975
+ // doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
23976
+ // testing. See `src/git/hashes.ts` for the matching rules.
23647
23977
  const target = action.hash;
23648
- const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
23978
+ const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
23649
23979
  if (index < 0) {
23650
23980
  return state;
23651
23981
  }
@@ -24288,10 +24618,14 @@ function applyLogInkAction(state, action) {
24288
24618
  // Cache the result so re-entry (or `c` to PR) reuses it instead of
24289
24619
  // re-running the LLM. Keyed by branch so a checkout naturally
24290
24620
  // produces a fresh generation.
24621
+ // Audit finding #9: `generatedAt` arrives on the action payload
24622
+ // instead of being read from `Date.now()` here, so the reducer
24623
+ // stays pure. Dispatchers (currently `runChangelogView` in
24624
+ // app.ts) call `Date.now()` at dispatch time.
24291
24625
  const cached = {
24292
24626
  text: action.text,
24293
24627
  baseLabel: action.baseLabel,
24294
- generatedAt: Date.now(),
24628
+ generatedAt: action.generatedAt,
24295
24629
  };
24296
24630
  return {
24297
24631
  ...state,
@@ -24345,7 +24679,8 @@ function applyLogInkAction(state, action) {
24345
24679
  // Updated-at timestamp reflects the edit. Not the original
24346
24680
  // generation time — `r` (regenerate) is the explicit knob
24347
24681
  // for "I want fresh LLM output, not my edits".
24348
- generatedAt: Date.now(),
24682
+ // Audit finding #9: timestamp arrives on the action.
24683
+ generatedAt: action.generatedAt,
24349
24684
  },
24350
24685
  },
24351
24686
  pendingKey: undefined,
@@ -24381,7 +24716,9 @@ function applyLogInkAction(state, action) {
24381
24716
  }
24382
24717
  return {
24383
24718
  ...state,
24384
- recentCommitHashes: { hashes: action.hashes, markedAt: Date.now() },
24719
+ // Audit finding #9: timestamp arrives on the action payload
24720
+ // instead of being read from `Date.now()` here.
24721
+ recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
24385
24722
  pendingKey: undefined,
24386
24723
  };
24387
24724
  case 'clearRecentCommits':
@@ -24575,7 +24912,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24575
24912
  const commit = state.filteredCommits[state.selectedIndex];
24576
24913
  const requireCommit = (fn) => {
24577
24914
  if (!commit) {
24578
- return [action({ type: 'setStatus', value: 'No commit selected' })];
24915
+ return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
24579
24916
  }
24580
24917
  return fn(commit.hash, state.selectedIndex);
24581
24918
  };
@@ -24614,6 +24951,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24614
24951
  return [action({
24615
24952
  type: 'setStatus',
24616
24953
  value: `Action ${inspectorAction.key} not yet wired`,
24954
+ kind: 'warning',
24617
24955
  })];
24618
24956
  }
24619
24957
  }
@@ -24828,6 +25166,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24828
25166
  return [action({
24829
25167
  type: 'setStatus',
24830
25168
  value: 'open the diff view and press [ or ] to jump hunks',
25169
+ kind: 'warning',
24831
25170
  })];
24832
25171
  case 'focusNext':
24833
25172
  return [action({ type: 'focusNext' })];
@@ -24876,6 +25215,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24876
25215
  return [action({
24877
25216
  type: 'setStatus',
24878
25217
  value: 'open branches / tags / history and press m on the cursored ref',
25218
+ kind: 'warning',
24879
25219
  })];
24880
25220
  case 'navigateBack':
24881
25221
  // Mirror the Esc / `<` semantics (#931): drain the frame's view
@@ -24951,6 +25291,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24951
25291
  return [action({
24952
25292
  type: 'setStatus',
24953
25293
  value: 'Sort cycle is available in the branches and tags views',
25294
+ kind: 'warning',
24954
25295
  })];
24955
25296
  case 'yankClipboard':
24956
25297
  // The runtime resolves the value/label against the live filtered
@@ -25007,7 +25348,7 @@ function submitInputPrompt(state) {
25007
25348
  return [];
25008
25349
  const value = state.inputPrompt.value.trim();
25009
25350
  if (!value) {
25010
- return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
25351
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
25011
25352
  }
25012
25353
  if (state.inputPrompt.kind === 'reset-mode') {
25013
25354
  const mode = value.toLowerCase();
@@ -25015,6 +25356,7 @@ function submitInputPrompt(state) {
25015
25356
  return [action({
25016
25357
  type: 'setStatus',
25017
25358
  value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
25359
+ kind: 'warning',
25018
25360
  })];
25019
25361
  }
25020
25362
  return [
@@ -25028,6 +25370,7 @@ function submitInputPrompt(state) {
25028
25370
  return [action({
25029
25371
  type: 'setStatus',
25030
25372
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25373
+ kind: 'warning',
25031
25374
  })];
25032
25375
  }
25033
25376
  return [
@@ -25091,6 +25434,7 @@ function submitInputPrompt(state) {
25091
25434
  return [action({
25092
25435
  type: 'setStatus',
25093
25436
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25437
+ kind: 'warning',
25094
25438
  })];
25095
25439
  }
25096
25440
  return [
@@ -25181,16 +25525,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25181
25525
  return [];
25182
25526
  }
25183
25527
  // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25184
- // surface is mid-stream (loading === true), Esc aborts the LLM call
25185
- // and the runtime handler cleans up (clear loading, clear preview,
25186
- // status line shows "AI draft cancelled."). Sits above the editing
25187
- // / view handlers so the cancel keystroke can't fall through to
25188
- // "leave compose" or anything else.
25528
+ // state has a draft in flight (loading === true), Esc aborts the
25529
+ // LLM call and the runtime handler cleans up (clear loading, clear
25530
+ // preview, status line shows "AI draft cancelled.").
25189
25531
  //
25190
- // Loading and editing are mutually exclusive in practice (the user
25191
- // can't type while the AI is generating), but the order here makes
25192
- // the precedence explicit if that ever changes.
25193
- if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
25532
+ // Audit finding #5: the `activeView === 'compose'` gate from the
25533
+ // original phase 3 implementation made the cancel keystroke
25534
+ // unreachable after the user chord-navigated away from compose
25535
+ // mid-stream (Esc would fall through to popView etc., consuming
25536
+ // the navigation intent while the LLM call silently ran to
25537
+ // completion). Cancel should work wherever the user is — they
25538
+ // can always navigate back to compose afterwards.
25539
+ //
25540
+ // Sits above the editing / view handlers so the cancel keystroke
25541
+ // can't fall through to "leave compose" or anything else. Loading
25542
+ // and editing are mutually exclusive in practice (the user can't
25543
+ // type while the AI is generating), but the order here makes the
25544
+ // precedence explicit if that ever changes.
25545
+ if (state.commitCompose.loading && key.escape) {
25194
25546
  return [{ type: 'cancelAiCommitDraft' }];
25195
25547
  }
25196
25548
  // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
@@ -25210,6 +25562,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25210
25562
  if (state.pendingPullRequestBodyDraft && key.escape) {
25211
25563
  return [{ type: 'cancelPullRequestBodyDraft' }];
25212
25564
  }
25565
+ // Pending AI draft confirmation (audit finding #7). When the AI
25566
+ // draft completes against a non-empty compose surface, it lands in
25567
+ // `pendingAiDraft` instead of overwriting the user's typing. `R`
25568
+ // accepts the swap (user's typing is lost, AI draft becomes the
25569
+ // new content). `Esc` dismisses the AI draft (typing is preserved,
25570
+ // AI draft is lost — the user paid for the tokens but explicitly
25571
+ // chose not to use them).
25572
+ //
25573
+ // Gated on `activeView === 'compose'` because the pending draft is
25574
+ // only meaningful on the compose surface (where the message line
25575
+ // surfaces the prompt). A user who chord-navigated away while the
25576
+ // draft was pending should see the original `R` / Esc semantics of
25577
+ // wherever they are now.
25578
+ if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
25579
+ if (inputValue === 'R' && !key.ctrl && !key.meta) {
25580
+ return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
25581
+ }
25582
+ if (key.escape) {
25583
+ return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
25584
+ }
25585
+ }
25213
25586
  if (state.commitCompose.editing) {
25214
25587
  if (key.escape) {
25215
25588
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -25659,7 +26032,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25659
26032
  }
25660
26033
  return [
25661
26034
  action({ type: 'setPendingKey', value: undefined }),
25662
- action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
26035
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
25663
26036
  ];
25664
26037
  }
25665
26038
  // `gT` chord: create a lightweight tag at the cursored commit on the
@@ -25683,7 +26056,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25683
26056
  }
25684
26057
  return [
25685
26058
  action({ type: 'setPendingKey', value: undefined }),
25686
- action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
26059
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
25687
26060
  ];
25688
26061
  }
25689
26062
  // #784 — bisect view action keys. Scoped to `state.activeView ===
@@ -25798,6 +26171,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25798
26171
  value: next === 'split'
25799
26172
  ? 'Switched to side-by-side diff'
25800
26173
  : 'Switched to unified diff',
26174
+ kind: 'success',
25801
26175
  }),
25802
26176
  ];
25803
26177
  }
@@ -26248,10 +26622,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26248
26622
  if (key.return && state.compareBase && isCompareFlowTarget(state)) {
26249
26623
  const head = getCursoredCompareRef(state, context);
26250
26624
  if (!head) {
26251
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26625
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26252
26626
  }
26253
26627
  if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
26254
- return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
26628
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
26255
26629
  }
26256
26630
  return [
26257
26631
  action({
@@ -26454,7 +26828,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26454
26828
  action({ type: 'setFocus', value: 'commits' }),
26455
26829
  ];
26456
26830
  }
26457
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
26831
+ return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
26458
26832
  }
26459
26833
  // Fall through — per-entity Enter handler below claims the keystroke.
26460
26834
  }
@@ -26565,7 +26939,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26565
26939
  if (inputValue === 'm' && isCompareFlowTarget(state)) {
26566
26940
  const ref = getCursoredCompareRef(state, context);
26567
26941
  if (!ref) {
26568
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26942
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26569
26943
  }
26570
26944
  if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
26571
26945
  return [
@@ -26796,7 +27170,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26796
27170
  // Always intercept `C` on the conflicts view to prevent fallthrough to
26797
27171
  // the global `C` (Create PR) binding when conflicts remain.
26798
27172
  if (inputValue === 'C' && state.activeView === 'conflicts') {
26799
- return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
27173
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
26800
27174
  }
26801
27175
  // Global `C` — create a pull request from the current branch. The
26802
27176
  // runtime callback handles pre-flight (current branch resolution,
@@ -26812,6 +27186,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26812
27186
  return [action({
26813
27187
  type: 'setStatus',
26814
27188
  value: 'Finish or cancel the commit draft before creating a PR.',
27189
+ kind: 'warning',
26815
27190
  })];
26816
27191
  }
26817
27192
  if (inputValue === 'C' && state.activeView !== 'conflicts') {
@@ -26871,7 +27246,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26871
27246
  return events;
26872
27247
  }
26873
27248
  if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
26874
- return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
27249
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
26875
27250
  }
26876
27251
  }
26877
27252
  // `c` on the history view cherry-picks the full selected commit on
@@ -27756,7 +28131,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
27756
28131
  rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
27757
28132
  tight: { min: 22, max: 28, fraction: 0.24 },
27758
28133
  normal: { min: 22, max: 30, fraction: 0.22 },
27759
- wide: { min: 28, max: 48, fraction: 0.24 },
28134
+ wide: { min: 28, max: 32, fraction: 0.20 },
27760
28135
  };
27761
28136
  function calcSidebarAtRestWidth(columns, density) {
27762
28137
  const config = SIDEBAR_AT_REST_BY_TIER[density];
@@ -29709,18 +30084,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
29709
30084
  ];
29710
30085
  }
29711
30086
 
30087
+ function resolveCursorSyncDecision(input) {
30088
+ if (!input.target) {
30089
+ return { type: 'noop', reason: 'no-target' };
30090
+ }
30091
+ if (input.target.hash === input.lastSyncedHash) {
30092
+ return { type: 'noop', reason: 'duplicate-of-last' };
30093
+ }
30094
+ if (isHashLoaded(input.target.hash, input.loadedHashes)) {
30095
+ return {
30096
+ type: 'jump',
30097
+ hash: input.target.hash,
30098
+ label: input.target.label,
30099
+ };
30100
+ }
30101
+ if (input.attemptedContextHashes.has(input.target.hash)) {
30102
+ return { type: 'unreachable', target: input.target };
30103
+ }
30104
+ return { type: 'load-context', target: input.target };
30105
+ }
30106
+ /**
30107
+ * Re-export of the shared `hashLoaded` helper under the resolver's
30108
+ * historical name. Kept exported so existing tests (and any external
30109
+ * importers) keep working unchanged — see `src/git/hashes.ts` for the
30110
+ * canonical implementation and the rationale behind bidirectional
30111
+ * prefix matching.
30112
+ */
30113
+ function isHashLoaded(hash, loadedHashes) {
30114
+ return hashLoaded(hash, loadedHashes);
30115
+ }
30116
+ /**
30117
+ * Build the membership set the resolver expects. Includes BOTH the
30118
+ * full hash and the short hash for every commit so the caller can
30119
+ * match either form (refs sometimes carry only the short hash and
30120
+ * `state.filteredCommits` items always have both).
30121
+ *
30122
+ * Exported so the cursor-sync effect can build the set once per
30123
+ * re-render and pass it down without leaking the implementation
30124
+ * detail. Tests use it to construct realistic inputs without
30125
+ * hand-rolling the dual-hash logic.
30126
+ */
30127
+ function buildLoadedHashSet(commits) {
30128
+ const set = new Set();
30129
+ for (const commit of commits) {
30130
+ if (commit.hash)
30131
+ set.add(commit.hash);
30132
+ if (commit.shortHash)
30133
+ set.add(commit.shortHash);
30134
+ }
30135
+ return set;
30136
+ }
30137
+
29712
30138
  /**
29713
- * Status-bar / footer renderer. Two-column layout:
29714
- * - Left: contextual hints for the active view (built by inkKeymap's
29715
- * `getLogInkFooterHints`), with the optional status message / idle
29716
- * tip appended.
29717
- * - Right: global key hints (`?` help, `:` palette, `q` quit, …).
30139
+ * Status-bar / footer renderer. Two-row layout, using the full
30140
+ * `height: 2` the footer already reserves:
30141
+ *
30142
+ * Row 1 — keyboard hint band:
30143
+ * ┌──── contextual hints ────┐ ┌──── globals ────┐
30144
+ * ↑/↓ branches ←/→ tab … ? help · : cmds · q
30145
+ *
30146
+ * Row 2 — status / feedback band:
30147
+ * ⠋ main has no upstream — nothing to fetch.
30148
+ *
30149
+ * Row 2 is empty when there's no status message, idle tip, or error.
30150
+ * This is a behaviour change from the pre-0.54.2 single-row layout
30151
+ * where the status message sat awkwardly between the contextual and
30152
+ * global hints, getting visually crushed.
29718
30153
  *
29719
- * Idle tips only fill the slot when no real status message is set so the
30154
+ * The separation matters because:
30155
+ * - status text and key hints serve different cognitive purposes
30156
+ * (read vs. scan) and competing for the same row makes both
30157
+ * harder to use,
30158
+ * - long status messages (especially errors / multi-clause loading
30159
+ * copy) no longer push global hints off screen or wrap into the
30160
+ * hint cluster,
30161
+ * - errors now keep the global hints visible — the user often
30162
+ * needs `?` / `:` / `q` to *recover* from the error.
30163
+ *
30164
+ * Idle tips fill row 2 only when no real status message is set so the
29720
30165
  * tip cycle never overwrites genuine workflow feedback.
29721
30166
  *
29722
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
29723
- * of #890. No behavior change.
30167
+ * Row 2 styling is kind-aware. Each statusKind gets its own theme
30168
+ * color and glyph prefix so the message is identifiable at a glance
30169
+ * — even with NO_COLOR set, the glyph alone communicates kind:
30170
+ *
30171
+ * loading → spinner + accent + bold
30172
+ * error → ✗ / ! + danger + bold
30173
+ * warning → ⚠ / ! + warning + bold
30174
+ * success → ✓ / + + success + bold
30175
+ * info → ℹ / i + info + bold
30176
+ * idle tip → no glyph + dim muted (passive)
30177
+ *
30178
+ * Pre-redesign success and loading both used `accent` (cyan), so the
30179
+ * user couldn't tell "done" from "in progress" by color alone. Each
30180
+ * kind now uses its dedicated theme color and ships an ASCII glyph
30181
+ * fallback for `theme.ascii` mode (TERM=dumb / vt100).
30182
+ *
30183
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30184
+ * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
30185
+ * colors + glyphs added in the same pass.
29724
30186
  */
29725
30187
  function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
29726
30188
  const { Box, Text } = components;
@@ -29752,50 +30214,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
29752
30214
  });
29753
30215
  // Real status messages always win; idle tips only fill the slot when it
29754
30216
  // would otherwise be empty.
29755
- const isLoading = Boolean(state.statusLoading && state.statusMessage);
29756
- const trailing = state.statusMessage || idleTip || '';
29757
- // Loading status gets a spinner prefix in front of the message —
29758
- // motion makes transient LLM calls (create-PR body, PR fetches,
29759
- // etc.) feel less frozen even when they're sub-second.
29760
- const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
29761
- const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
29762
- const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
30217
+ const hasStatusMessage = Boolean(state.statusMessage);
30218
+ const isLoading = Boolean(state.statusLoading && hasStatusMessage);
29763
30219
  const isError = state.statusKind === 'error';
30220
+ const isWarning = state.statusKind === 'warning';
29764
30221
  const isSuccess = state.statusKind === 'success';
29765
- const contextualText = isError
29766
- // Errors get the full footer width and a `✗` prefix so they read
29767
- // as alarming. We drop the contextual hints when an error is
29768
- // active they'd compete for attention with the message and
29769
- // long validator outputs (#907 polish: split-plan validator
29770
- // errors are often 100+ chars and got truncated against the hints).
29771
- ? `✗ ${state.statusMessage || ''}`
29772
- : `${hints.contextual.join(' ')}${status}`;
30222
+ // 'info' is the implicit kind when statusKind is undefined but
30223
+ // statusMessage is set it's a deliberate status update, not an
30224
+ // idle tip, so it gets info treatment rather than the dim fallback.
30225
+ const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
30226
+ const rawTrailing = state.statusMessage || idleTip || '';
30227
+ // Glyphs per kind so the message is identifiable even before reading
30228
+ // the color improves scan-ability and degrades gracefully when the
30229
+ // terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
30230
+ // = dumb / vt100) where unicode glyphs render as garbage.
30231
+ // loading → spinner (animated)
30232
+ // error → ✗ / !
30233
+ // warning → ⚠ / !
30234
+ // success → ✓ / +
30235
+ // info → ℹ / i
30236
+ // idle tip → no glyph (passive)
30237
+ const glyph = (() => {
30238
+ if (isLoading)
30239
+ return pickSpinnerFrame(spinnerFrame);
30240
+ if (isError)
30241
+ return theme.ascii ? '!' : '✗';
30242
+ if (isWarning)
30243
+ return theme.ascii ? '!' : '⚠';
30244
+ if (isSuccess)
30245
+ return theme.ascii ? '+' : '✓';
30246
+ if (isInfo)
30247
+ return theme.ascii ? 'i' : 'ℹ';
30248
+ return '';
30249
+ })();
30250
+ const statusBody = rawTrailing
30251
+ ? glyph
30252
+ ? `${glyph} ${rawTrailing}`
30253
+ : rawTrailing
30254
+ : '';
30255
+ // Row 2 color picks. Each kind gets its own theme color so success
30256
+ // and loading are visually distinct (was conflated under `accent`
30257
+ // pre-redesign — users couldn't tell "done" from "in progress").
30258
+ // loading → accent (cyan / preset blue)
30259
+ // error → danger (red / preset red)
30260
+ // warning → warning (yellow)
30261
+ // success → success (green)
30262
+ // info → info (blue / preset accent in light themes)
30263
+ // idle → undefined + dim (passive, blends with chrome)
30264
+ const statusColor = isError
30265
+ ? theme.colors.danger
30266
+ : isWarning
30267
+ ? theme.colors.warning
30268
+ : isSuccess
30269
+ ? theme.colors.success
30270
+ : isLoading
30271
+ ? theme.colors.accent
30272
+ : isInfo
30273
+ ? theme.colors.info
30274
+ : undefined;
30275
+ const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
30276
+ const statusDim = !statusBold;
30277
+ const hintsText = hints.contextual.join(' ');
29773
30278
  const globalText = hints.global.join(' · ');
29774
- // Error rendering: hide the global hints on the right so the
29775
- // message can wrap into that space. Success rendering: accent
29776
- // color on the message, hints stay visible. Default: existing
29777
- // muted styling.
29778
- const contextualColor = isError
29779
- ? 'red'
29780
- : isSuccess
29781
- ? theme.colors.accent
29782
- : theme.colors.muted;
29783
- return h(Box, {
29784
- flexDirection: 'row',
29785
- height: 2,
29786
- justifyContent: 'space-between',
29787
- paddingX: 1,
29788
- }, h(Text, {
29789
- color: contextualColor,
29790
- dimColor: !isError && !isSuccess,
29791
- bold: isError,
29792
- }, contextualText),
29793
- // Globals are dropped entirely when an error is on screen — that
29794
- // space is what the long message needs to render. They come back
29795
- // the moment the status flips to info / success / cleared.
29796
- isError
29797
- ? h(Text, undefined, '')
29798
- : h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
30279
+ return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
30280
+ // Row 1: contextual global hints. justifyContent pushes them
30281
+ // to opposite edges so the eye can scan each cluster as one
30282
+ // block instead of hunting through a single concatenated line.
30283
+ h(Box, { flexDirection: 'row', justifyContent: 'space-between' }, h(Text, { color: theme.colors.muted, dimColor: true }, hintsText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText)),
30284
+ // Row 2: status / loading / idle tip / error. Empty Text keeps
30285
+ // the row reserved when nothing's set so the surrounding layout
30286
+ // doesn't shift as status flips on/off.
30287
+ h(Text, {
30288
+ color: statusColor,
30289
+ dimColor: statusDim,
30290
+ bold: statusBold,
30291
+ }, statusBody));
30292
+ }
30293
+
30294
+ const COMBINING_MARK_RANGES = [
30295
+ [0x0300, 0x036f],
30296
+ [0x1ab0, 0x1aff],
30297
+ [0x1dc0, 0x1dff],
30298
+ [0x20d0, 0x20ff],
30299
+ [0xfe20, 0xfe2f],
30300
+ ];
30301
+ const WIDE_CHARACTER_RANGES = [
30302
+ [0x1100, 0x115f],
30303
+ [0x2329, 0x232a],
30304
+ [0x2e80, 0xa4cf],
30305
+ [0xac00, 0xd7a3],
30306
+ [0xf900, 0xfaff],
30307
+ [0xfe10, 0xfe19],
30308
+ [0xfe30, 0xfe6f],
30309
+ [0xff00, 0xff60],
30310
+ [0xffe0, 0xffe6],
30311
+ [0x2600, 0x27bf],
30312
+ [0x1f000, 0x1f9ff],
30313
+ [0x20000, 0x3fffd],
30314
+ ];
30315
+ function isInRange(codePoint, ranges) {
30316
+ return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30317
+ }
30318
+ function characterWidth(character) {
30319
+ const codePoint = character.codePointAt(0) || 0;
30320
+ if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30321
+ return 0;
30322
+ }
30323
+ if (codePoint === 0x200d ||
30324
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30325
+ isInRange(codePoint, COMBINING_MARK_RANGES)) {
30326
+ return 0;
30327
+ }
30328
+ return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30329
+ }
30330
+ function cellWidth(value) {
30331
+ return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30332
+ }
30333
+ /**
30334
+ * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30335
+ * on whitespace where possible; falls back to mid-word splits when a single
30336
+ * word is wider than the budget. Preserves blank input as a single empty
30337
+ * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30338
+ */
30339
+ function wrapCells(value, width) {
30340
+ if (width < 1) {
30341
+ return [value];
30342
+ }
30343
+ if (cellWidth(value) <= width) {
30344
+ return [value];
30345
+ }
30346
+ const lines = [];
30347
+ let current = '';
30348
+ let currentWidth = 0;
30349
+ const flush = () => {
30350
+ if (current.length > 0) {
30351
+ lines.push(current);
30352
+ current = '';
30353
+ currentWidth = 0;
30354
+ }
30355
+ };
30356
+ // Tokenize into runs of whitespace + non-whitespace so we can keep word
30357
+ // boundaries when possible.
30358
+ const tokens = value.match(/\s+|\S+/g) || [];
30359
+ for (const token of tokens) {
30360
+ const tokenWidth = cellWidth(token);
30361
+ if (currentWidth + tokenWidth <= width) {
30362
+ current += token;
30363
+ currentWidth += tokenWidth;
30364
+ continue;
30365
+ }
30366
+ if (/^\s+$/.test(token)) {
30367
+ // Drop boundary whitespace at line breaks.
30368
+ flush();
30369
+ continue;
30370
+ }
30371
+ flush();
30372
+ if (tokenWidth <= width) {
30373
+ current = token;
30374
+ currentWidth = tokenWidth;
30375
+ continue;
30376
+ }
30377
+ // Word longer than budget — hard-split into chunks.
30378
+ let remaining = token;
30379
+ while (cellWidth(remaining) > width) {
30380
+ let chunk = '';
30381
+ let chunkWidth = 0;
30382
+ for (const character of Array.from(remaining)) {
30383
+ const charW = characterWidth(character);
30384
+ if (chunkWidth + charW > width)
30385
+ break;
30386
+ chunk += character;
30387
+ chunkWidth += charW;
30388
+ }
30389
+ lines.push(chunk);
30390
+ remaining = remaining.slice(chunk.length);
30391
+ }
30392
+ if (remaining.length > 0) {
30393
+ current = remaining;
30394
+ currentWidth = cellWidth(remaining);
30395
+ }
30396
+ }
30397
+ flush();
30398
+ return lines.length > 0 ? lines : [value];
30399
+ }
30400
+ function truncateCells(value, width) {
30401
+ if (width < 1) {
30402
+ return '';
30403
+ }
30404
+ if (cellWidth(value) <= width) {
30405
+ return value;
30406
+ }
30407
+ const suffix = width > 3 ? '...' : '';
30408
+ const available = width - cellWidth(suffix);
30409
+ let used = 0;
30410
+ let output = '';
30411
+ for (const character of Array.from(value)) {
30412
+ const nextWidth = characterWidth(character);
30413
+ if (used + nextWidth > available) {
30414
+ break;
30415
+ }
30416
+ output += character;
30417
+ used += nextWidth;
30418
+ }
30419
+ return `${output}${suffix}`;
30420
+ }
30421
+ /**
30422
+ * Truncate a file path so the filename (last segment) is preserved,
30423
+ * eliding middle directory segments with `…/` instead of dropping
30424
+ * end-of-string characters.
30425
+ *
30426
+ * `truncateCells` is the wrong tool for paths because it preserves the
30427
+ * START of the string and drops the END — losing the filename, which
30428
+ * is the most useful part. Example with `truncateCells`:
30429
+ *
30430
+ * "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
30431
+ *
30432
+ * `truncatePathCells` preserves the filename and elides middle:
30433
+ *
30434
+ * "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
30435
+ *
30436
+ * The algorithm tries successively-smaller prefixes (keeping the start
30437
+ * of the path, the filename, and replacing the dropped middle segments
30438
+ * with `…`) and returns the largest variant that fits. When even
30439
+ * `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
30440
+ * the abbreviated form — better to show end-of-name than start-of-path.
30441
+ *
30442
+ * For inputs without `/` separators, behaves identically to
30443
+ * `truncateCells`. Empty / width-0 cases match `truncateCells` too.
30444
+ *
30445
+ * @example
30446
+ * truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
30447
+ * truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
30448
+ * truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
30449
+ * truncatePathCells('plainname.ts', 8) // 'plain...'
30450
+ */
30451
+ function truncatePathCells(value, width) {
30452
+ if (width < 1)
30453
+ return '';
30454
+ if (cellWidth(value) <= width)
30455
+ return value;
30456
+ // No path structure to exploit — fall through to plain truncation.
30457
+ if (!value.includes('/'))
30458
+ return truncateCells(value, width);
30459
+ const segments = value.split('/');
30460
+ const filename = segments[segments.length - 1] ?? '';
30461
+ const prefix = segments.slice(0, -1);
30462
+ // Path is just '/filename' or has only the filename — no middle to
30463
+ // elide. Defer to plain truncation.
30464
+ if (prefix.length === 0)
30465
+ return truncateCells(value, width);
30466
+ // Walk from "keep all prefix segments except the deepest" down to
30467
+ // "keep no prefix segments." First variant that fits wins.
30468
+ for (let keep = prefix.length - 1; keep >= 0; keep--) {
30469
+ const candidate = keep === 0
30470
+ ? `…/${filename}`
30471
+ : `${prefix.slice(0, keep).join('/')}/…/${filename}`;
30472
+ if (cellWidth(candidate) <= width)
30473
+ return candidate;
30474
+ }
30475
+ // Even `…/<filename>` doesn't fit. Use plain truncation on that
30476
+ // form — preserves the leading `…/` so the user knows a path was
30477
+ // elided, then ellipsis-truncates the filename.
30478
+ return truncateCells(`…/${filename}`, width);
29799
30479
  }
29800
30480
 
29801
30481
  /**
@@ -30022,218 +30702,318 @@ function sidebarTabCount(tab, context) {
30022
30702
  }
30023
30703
  }
30024
30704
 
30025
- const COMBINING_MARK_RANGES = [
30026
- [0x0300, 0x036f],
30027
- [0x1ab0, 0x1aff],
30028
- [0x1dc0, 0x1dff],
30029
- [0x20d0, 0x20ff],
30030
- [0xfe20, 0xfe2f],
30031
- ];
30032
- const WIDE_CHARACTER_RANGES = [
30033
- [0x1100, 0x115f],
30034
- [0x2329, 0x232a],
30035
- [0x2e80, 0xa4cf],
30036
- [0xac00, 0xd7a3],
30037
- [0xf900, 0xfaff],
30038
- [0xfe10, 0xfe19],
30039
- [0xfe30, 0xfe6f],
30040
- [0xff00, 0xff60],
30041
- [0xffe0, 0xffe6],
30042
- [0x2600, 0x27bf],
30043
- [0x1f000, 0x1f9ff],
30044
- [0x20000, 0x3fffd],
30045
- ];
30046
- function isInRange(codePoint, ranges) {
30047
- return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30048
- }
30049
- function characterWidth(character) {
30050
- const codePoint = character.codePointAt(0) || 0;
30051
- if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30052
- return 0;
30053
- }
30054
- if (codePoint === 0x200d ||
30055
- (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30056
- isInRange(codePoint, COMBINING_MARK_RANGES)) {
30057
- return 0;
30058
- }
30059
- return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30060
- }
30061
- function cellWidth(value) {
30062
- return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30063
- }
30064
30705
  /**
30065
- * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30066
- * on whitespace where possible; falls back to mid-word splits when a single
30067
- * word is wider than the budget. Preserves blank input as a single empty
30068
- * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30706
+ * Header chip builder. Turns the workstation's title-bar state into an
30707
+ * ordered list of small visually-distinct chips:
30708
+ *
30709
+ * coco · gfargo/coco · main · ✓ clean · ⊘ no PR · [NORMAL]
30710
+ *
30711
+ * Pre-refactor the title bar concatenated every segment into a single
30712
+ * Text span, which made the eye read the whole thing as one run of
30713
+ * words (the same problem the footer had). Splitting into chips with a
30714
+ * fixed separator lets each segment carry its own color and lets the
30715
+ * user scan the bar in chunks — "what app, what repo, what branch,
30716
+ * how clean, what PR state, what mode" — instead of parsing left-to-
30717
+ * right.
30718
+ *
30719
+ * Why a separate module: the header runtime renders chips and handles
30720
+ * truncation; chip construction is pure transformation of state +
30721
+ * context + theme. Splitting them keeps the chips testable in
30722
+ * isolation and keeps the runtime small.
30723
+ *
30724
+ * Truncation strategy lives in the consumer, not here — when the total
30725
+ * width exceeds the column budget, the header falls back to the
30726
+ * pre-redesign single-fragment truncated string so the ellipsis can't
30727
+ * land mid-glyph. We always return the FULL chip list; the consumer
30728
+ * decides whether to drop chips, fall back, or render all of them.
30069
30729
  */
30070
- function wrapCells(value, width) {
30071
- if (width < 1) {
30072
- return [value];
30730
+ /**
30731
+ * Default separator inserted between chips by the consumer. Exported as
30732
+ * a constant so tests and width math agree on what they're measuring.
30733
+ * The trailing/leading spaces are part of the separator — `·` alone
30734
+ * would butt against adjacent chip labels.
30735
+ */
30736
+ const HEADER_CHIP_SEPARATOR = ' · ';
30737
+ /**
30738
+ * Build the ordered chip list for the header. Chips not relevant to the
30739
+ * current state (no PR loaded, no breadcrumb, no search input, …) are
30740
+ * omitted entirely rather than rendered as empty placeholders, so the
30741
+ * consumer can just `chips.map(render)` without checking for empties.
30742
+ */
30743
+ function buildHeaderChips(input) {
30744
+ const { theme } = input;
30745
+ const chips = [];
30746
+ // App label — the constant identity. Accent + bold so it anchors the
30747
+ // left edge of the bar.
30748
+ chips.push({
30749
+ id: 'app',
30750
+ label: input.appLabel,
30751
+ color: theme.colors.accent,
30752
+ dim: false,
30753
+ bold: true,
30754
+ });
30755
+ // Repo. Default color — it's contextual but not the headline.
30756
+ chips.push({
30757
+ id: 'repo',
30758
+ label: input.repo,
30759
+ color: undefined,
30760
+ dim: false,
30761
+ bold: false,
30762
+ });
30763
+ // Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
30764
+ // is identifiable even when the branch name is generic ("main" /
30765
+ // "master").
30766
+ const branchGlyph = theme.ascii ? 'git:' : '⎇';
30767
+ chips.push({
30768
+ id: 'branch',
30769
+ label: `${branchGlyph} ${input.branch}`,
30770
+ color: theme.colors.accent,
30771
+ dim: false,
30772
+ bold: true,
30773
+ });
30774
+ // Dirty/clean. Positive framing on clean (success color + ✓), warning
30775
+ // on dirty (warning color + ●). ASCII fallbacks keep the chip
30776
+ // identifiable on dumb terminals.
30777
+ const dirtyChip = input.dirty
30778
+ ? {
30779
+ id: 'dirty',
30780
+ label: theme.ascii ? '* dirty' : '● dirty',
30781
+ color: theme.colors.warning,
30782
+ dim: false,
30783
+ bold: false,
30784
+ }
30785
+ : {
30786
+ id: 'dirty',
30787
+ label: theme.ascii ? '+ clean' : '✓ clean',
30788
+ color: theme.colors.success,
30789
+ dim: false,
30790
+ bold: false,
30791
+ };
30792
+ chips.push(dirtyChip);
30793
+ // Bisect — only when active. Distinct chip so users entering the TUI
30794
+ // mid-bisect see it immediately (#784). Warning color because bisect
30795
+ // is an "in progress, requires user action" state.
30796
+ if (input.bisecting) {
30797
+ chips.push({
30798
+ id: 'bisecting',
30799
+ label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
30800
+ color: theme.colors.warning,
30801
+ dim: false,
30802
+ bold: true,
30803
+ });
30073
30804
  }
30074
- if (cellWidth(value) <= width) {
30075
- return [value];
30805
+ // PR state. When present, the chip uses the PR-state glyph + a short
30806
+ // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
30807
+ // "no PR" chip so users know the system DID look (vs. the bar just
30808
+ // being blank).
30809
+ if (input.pullRequest) {
30810
+ const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
30811
+ const stateLabel = input.pullRequest.isDraft
30812
+ ? 'DRAFT'
30813
+ : input.pullRequest.state.toUpperCase();
30814
+ const label = prGlyph.glyph
30815
+ ? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
30816
+ : `PR #${input.pullRequest.number} ${stateLabel}`;
30817
+ chips.push({
30818
+ id: 'pr',
30819
+ label,
30820
+ color: prGlyph.color,
30821
+ dim: prGlyph.dim,
30822
+ bold: false,
30823
+ });
30076
30824
  }
30077
- const lines = [];
30078
- let current = '';
30079
- let currentWidth = 0;
30080
- const flush = () => {
30081
- if (current.length > 0) {
30082
- lines.push(current);
30083
- current = '';
30084
- currentWidth = 0;
30085
- }
30086
- };
30087
- // Tokenize into runs of whitespace + non-whitespace so we can keep word
30088
- // boundaries when possible.
30089
- const tokens = value.match(/\s+|\S+/g) || [];
30090
- for (const token of tokens) {
30091
- const tokenWidth = cellWidth(token);
30092
- if (currentWidth + tokenWidth <= width) {
30093
- current += token;
30094
- currentWidth += tokenWidth;
30095
- continue;
30096
- }
30097
- if (/^\s+$/.test(token)) {
30098
- // Drop boundary whitespace at line breaks.
30099
- flush();
30100
- continue;
30101
- }
30102
- flush();
30103
- if (tokenWidth <= width) {
30104
- current = token;
30105
- currentWidth = tokenWidth;
30106
- continue;
30107
- }
30108
- // Word longer than budget — hard-split into chunks.
30109
- let remaining = token;
30110
- while (cellWidth(remaining) > width) {
30111
- let chunk = '';
30112
- let chunkWidth = 0;
30113
- for (const character of Array.from(remaining)) {
30114
- const charW = characterWidth(character);
30115
- if (chunkWidth + charW > width)
30116
- break;
30117
- chunk += character;
30118
- chunkWidth += charW;
30119
- }
30120
- lines.push(chunk);
30121
- remaining = remaining.slice(chunk.length);
30122
- }
30123
- if (remaining.length > 0) {
30124
- current = remaining;
30125
- currentWidth = cellWidth(remaining);
30126
- }
30825
+ else {
30826
+ chips.push({
30827
+ id: 'pr',
30828
+ label: theme.ascii ? '- no PR' : '⊘ no PR',
30829
+ color: theme.colors.muted,
30830
+ dim: true,
30831
+ bold: false,
30832
+ });
30127
30833
  }
30128
- flush();
30129
- return lines.length > 0 ? lines : [value];
30130
- }
30131
- function truncateCells(value, width) {
30132
- if (width < 1) {
30133
- return '';
30834
+ // View breadcrumb. Rendered only when there's content (`coco ui`
30835
+ // root view no breadcrumb chip; pushed into a sub-view → chip
30836
+ // appears). Comes AFTER PR so the "state" group (app/repo/branch/
30837
+ // dirty/PR) reads as one cluster and the "navigation" group (view
30838
+ // breadcrumb / loading) reads as a separate cluster.
30839
+ if (input.breadcrumb) {
30840
+ chips.push({
30841
+ id: 'view',
30842
+ label: input.breadcrumb,
30843
+ color: theme.colors.muted,
30844
+ dim: true,
30845
+ bold: false,
30846
+ });
30134
30847
  }
30135
- if (cellWidth(value) <= width) {
30136
- return value;
30848
+ if (input.loading) {
30849
+ chips.push({
30850
+ id: 'loading',
30851
+ label: input.loading.trim(),
30852
+ color: theme.colors.muted,
30853
+ dim: true,
30854
+ bold: false,
30855
+ });
30137
30856
  }
30138
- const suffix = width > 3 ? '...' : '';
30139
- const available = width - cellWidth(suffix);
30140
- let used = 0;
30141
- let output = '';
30142
- for (const character of Array.from(value)) {
30143
- const nextWidth = characterWidth(character);
30144
- if (used + nextWidth > available) {
30145
- break;
30146
- }
30147
- output += character;
30148
- used += nextWidth;
30857
+ // Mode the explicit input-mode indicator (#P2.2). Always present
30858
+ // so users never wonder why `q` doesn't quit while they're editing.
30859
+ // EDIT / FILTER use the warning color to signal "your keystrokes
30860
+ // mean something different right now"; NORMAL uses accent (matches
30861
+ // the app chip's home base).
30862
+ const modeColor = input.mode === 'NORMAL'
30863
+ ? theme.colors.accent
30864
+ : theme.colors.warning;
30865
+ chips.push({
30866
+ id: 'mode',
30867
+ label: `[${input.mode}]`,
30868
+ color: modeColor,
30869
+ dim: false,
30870
+ bold: true,
30871
+ });
30872
+ // Search — only when active. Dim so it doesn't compete with the
30873
+ // identity chips for attention; the user knows it's there because
30874
+ // they're typing into it.
30875
+ if (input.search) {
30876
+ chips.push({
30877
+ id: 'search',
30878
+ label: input.search,
30879
+ color: theme.colors.muted,
30880
+ dim: true,
30881
+ bold: false,
30882
+ });
30149
30883
  }
30150
- return `${output}${suffix}`;
30884
+ return chips;
30885
+ }
30886
+ /**
30887
+ * Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
30888
+ * between every pair. Used by the consumer to decide whether the
30889
+ * chip layout fits the column budget or whether to fall back to the
30890
+ * single-fragment truncated path.
30891
+ */
30892
+ function measureHeaderChipsWidth(chips) {
30893
+ if (chips.length === 0)
30894
+ return 0;
30895
+ const labels = chips.map((chip) => cellWidth(chip.label));
30896
+ const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
30897
+ return labels.reduce((sum, w) => sum + w, 0) + separators;
30151
30898
  }
30152
30899
 
30153
30900
  /**
30154
- * Title-bar renderer. Surfaces:
30155
- * - the app label (e.g. "coco ui")
30156
- * - current repo owner/name (or "local repository")
30157
- * - current branch + dirty / BISECTING flag
30158
- * - PR glyph + label when one is detected
30159
- * - breadcrumb of the view stack
30160
- * - loading hint for boot / context fetches
30161
- * - mode indicator: [NORMAL] / [EDIT] / [FILTER]
30162
- * - active filter / search input
30901
+ * Title-bar renderer. Surfaces the workstation's identity + navigation
30902
+ * state as a row of small visually-distinct chips:
30163
30903
  *
30164
- * Truncation: when the assembled title overruns the available columns we
30165
- * fall back to a single-fragment Text (truncating the joined string) so
30166
- * the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
30167
- * glyph in its own colored span when there's headroom.
30904
+ * coco · gfargo/coco · main · clean · ⊘ no PR · [NORMAL]
30168
30905
  *
30169
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
30170
- * of #890. No behavior change.
30906
+ * Per-chip color/glyph treatment lets the user scan in chunks ("what
30907
+ * app, what repo, what branch, how clean, what PR state, what mode")
30908
+ * instead of parsing one long sentence. Chip construction is in
30909
+ * `chrome/headerChips.ts`; this runtime just renders.
30910
+ *
30911
+ * Truncation: when the assembled chip row overruns the available
30912
+ * columns we fall back to a single Text fragment (truncating the
30913
+ * joined chip labels) so the ellipsis can't land mid-glyph. This is
30914
+ * the same defensive pattern the pre-redesign single-fragment code
30915
+ * used, applied at the chip-list level instead of the inline glyph
30916
+ * split.
30917
+ *
30918
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30919
+ * 5a.7 of #890. Chip restructuring introduced post-0.54.2.
30171
30920
  */
30172
30921
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
30173
30922
  const { Box, Text } = components;
30923
+ // Pull the source state into the small "describe what to render"
30924
+ // shape the chip builder expects. Keeps the runtime decoupled from
30925
+ // the chip layout — the builder doesn't know about LogInkState /
30926
+ // LogInkContext, just plain values.
30174
30927
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
30175
- // #784 surface bisect-in-progress in the title bar so users entering
30176
- // the TUI mid-bisect see it immediately, before they navigate to gB.
30177
- const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
30178
- const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
30928
+ const dirty = Boolean(context.branches?.dirty);
30929
+ const bisecting = Boolean(context.bisect?.active);
30179
30930
  const repo = context.provider?.repository.owner && context.provider.repository.name
30180
30931
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
30181
30932
  : 'local repository';
30182
30933
  const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
30183
- const prGlyph = prInfo ? getPullRequestStateGlyph(prInfo, theme) : null;
30184
- const prLabel = prInfo
30185
- ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
30186
- : 'no PR';
30187
- const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
30188
- // Boot loading wins over the per-context loading hint because it
30189
- // tells the user the headline thing they care about (commits aren't
30190
- // ready yet) — the context fetches finish independently and surface
30191
- // their own per-section loading copy in the sidebars.
30934
+ // Boot loading wins over the per-context loading hint — same
30935
+ // priority as pre-redesign. Context fetches still surface their own
30936
+ // copy in the sidebars.
30192
30937
  const loading = state.bootLoading
30193
- ? ' loading commits'
30194
- : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
30938
+ ? 'loading commits'
30939
+ : isLogInkContextLoading(contextStatus) ? 'loading context' : '';
30195
30940
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
30196
30941
  const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
30197
- // Repo breadcrumb (when nested) comes first so the user sees which
30198
- // submodule they're in at a glance, then the view breadcrumb (when
30199
- // pushed deeper than the root view). The truncate fallback in the
30200
- // title row still applies — when both fight for space, the ellipsis
30201
- // lands at the end of whichever segment overflows.
30202
30942
  const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
30203
- // Mode indicator (P2.2) — surfaces the current input mode so users
30204
- // never wonder why `q` doesn't quit while they're editing or filtering.
30205
30943
  const mode = state.commitCompose.editing
30206
- ? '[EDIT]'
30944
+ ? 'EDIT'
30207
30945
  : state.filterMode
30208
- ? '[FILTER]'
30209
- : '[NORMAL]';
30210
- const titlePrefix = `${appLabel} ${repo} ${branch} ${dirty} `;
30211
- const glyphPart = prGlyph?.glyph ? `${prGlyph.glyph} ` : '';
30212
- const titleSuffix = `${view}${loading}`;
30213
- const fullTitle = `${titlePrefix}${glyphPart}${prLabel}${titleSuffix}`;
30214
- const titleBudget = columns - mode.length - 4;
30215
- const truncatedTitle = truncateCells(fullTitle, titleBudget);
30216
- // Only split into colored fragments when the prefix + glyph + label all
30217
- // fit unmodified — otherwise the truncate ellipsis can land mid-fragment
30218
- // and we'd render half a glyph in the wrong color.
30219
- const splitFragments = truncatedTitle === fullTitle && glyphPart.length > 0;
30220
- const modeColor = theme.noColor
30221
- ? undefined
30222
- : state.filterMode || state.commitCompose.editing
30223
- ? theme.colors.warning
30224
- : theme.colors.accent;
30946
+ ? 'FILTER'
30947
+ : 'NORMAL';
30948
+ const search = state.filterMode
30949
+ ? `search: ${state.filter}_`
30950
+ : state.filter
30951
+ ? `filter: ${state.filter}`
30952
+ : '';
30953
+ const chips = buildHeaderChips({
30954
+ appLabel,
30955
+ repo,
30956
+ branch,
30957
+ dirty,
30958
+ bisecting,
30959
+ pullRequest: prInfo ? {
30960
+ number: prInfo.number,
30961
+ state: prInfo.state,
30962
+ isDraft: prInfo.isDraft,
30963
+ } : undefined,
30964
+ breadcrumb: view,
30965
+ loading,
30966
+ mode,
30967
+ search: search ? truncateCells(search, 36) : '',
30968
+ theme,
30969
+ });
30970
+ // Truncation budget. Header line gets the full terminal width minus
30971
+ // the box's horizontal padding (2 cells) and a small safety margin.
30972
+ const budget = Math.max(0, columns - 4);
30973
+ const chipsWidth = measureHeaderChipsWidth(chips);
30225
30974
  return h(Box, {
30226
30975
  borderColor: theme.colors.border,
30227
30976
  borderStyle: theme.borderStyle,
30228
30977
  height: 3,
30229
30978
  paddingX: 1,
30230
- }, splitFragments
30231
- ? h(Text, { bold: true, color: theme.colors.accent }, titlePrefix)
30232
- : h(Text, { bold: true, color: theme.colors.accent }, truncatedTitle), splitFragments
30233
- ? h(Text, { bold: true, color: prGlyph?.color, dimColor: prGlyph?.dim }, glyphPart)
30234
- : undefined, splitFragments
30235
- ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
30236
- : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncateCells(search, 36)}`) : undefined);
30979
+ }, chipsWidth <= budget
30980
+ ? renderChipRow(h, Text, chips)
30981
+ : renderFallback(h, Text, chips, theme, budget));
30982
+ }
30983
+ /**
30984
+ * Render every chip as its own Text span with its own color/style,
30985
+ * interleaved with dim separator spans. This is the path used when
30986
+ * everything fits — the eye gets the full chip treatment.
30987
+ */
30988
+ function renderChipRow(h, Text, chips) {
30989
+ const nodes = [];
30990
+ chips.forEach((chip, index) => {
30991
+ if (index > 0) {
30992
+ // Separator is intentionally dim so the eye can use it as a
30993
+ // visual delimiter without it competing with chip labels for
30994
+ // attention.
30995
+ nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
30996
+ }
30997
+ nodes.push(h(Text, {
30998
+ key: chip.id,
30999
+ color: chip.color,
31000
+ dimColor: chip.dim,
31001
+ bold: chip.bold,
31002
+ }, chip.label));
31003
+ });
31004
+ return nodes;
31005
+ }
31006
+ /**
31007
+ * Fallback path for narrow terminals. Concatenates every chip label
31008
+ * with separators, then truncates the whole string with
31009
+ * `truncateCells` so the ellipsis lands at a cell boundary. Loses the
31010
+ * per-chip color treatment in exchange for guaranteed legibility on
31011
+ * narrow displays — the same trade-off the pre-redesign single-
31012
+ * fragment code made for its inline glyph color split.
31013
+ */
31014
+ function renderFallback(h, Text, chips, theme, budget) {
31015
+ const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
31016
+ return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
30237
31017
  }
30238
31018
 
30239
31019
  /**
@@ -30456,10 +31236,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
30456
31236
  const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
30457
31237
  const fileRows = worktree.files.slice(0, 12).map((file, index) => {
30458
31238
  const codes = `${file.indexStatus}${file.worktreeStatus}`;
31239
+ // Smart path truncation: keep the leading status codes and elide
31240
+ // middle directory segments to preserve the filename. Falls back
31241
+ // to plain truncation when the codes + a meaningful filename
31242
+ // don't both fit. Same shape as the detail surface so all the
31243
+ // status-row renderings elide consistently.
31244
+ const prefix = ` ${codes} `;
31245
+ const totalBudget = width - 4;
31246
+ const pathBudget = totalBudget - cellWidth(prefix);
31247
+ const label = pathBudget >= 8
31248
+ ? `${prefix}${truncatePathCells(file.path, pathBudget)}`
31249
+ : truncateCells(`${prefix}${file.path}`, totalBudget);
30459
31250
  return h(Text, {
30460
31251
  key: `tab-status-file-${index}`,
30461
31252
  color: colorOf(file.state),
30462
- }, truncateCells(` ${codes} ${file.path}`, width - 4));
31253
+ }, label);
30463
31254
  });
30464
31255
  return [
30465
31256
  summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
@@ -31802,7 +32593,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
31802
32593
  color: theme.noColor ? undefined : theme.colors.accent,
31803
32594
  backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
31804
32595
  inverse: isActive && focused,
31805
- }, truncateCells(`${arrow}${headerFile.path}`, width - 4));
32596
+ }, (() => {
32597
+ // Smart path truncation for the diff file header: keep
32598
+ // the leading arrow glyph and elide middle path
32599
+ // segments so the filename is never lost. Falls back to
32600
+ // plain truncation when there isn't room for a
32601
+ // meaningful filename.
32602
+ const pathBudget = (width - 4) - cellWidth(arrow);
32603
+ return pathBudget >= 8
32604
+ ? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
32605
+ : truncateCells(`${arrow}${headerFile.path}`, width - 4);
32606
+ })());
31806
32607
  }
31807
32608
  return h(Text, {
31808
32609
  key: `stash-diff-line-${absoluteIndex}`,
@@ -35283,6 +36084,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
35283
36084
  });
35284
36085
  return out;
35285
36086
  }
36087
+ /**
36088
+ * Compose a `<prefix><path><suffix>` line where the path gets smart
36089
+ * middle-elision truncation if needed, while the fixed prefix/suffix
36090
+ * decorations stay intact. Falls back to plain whole-line truncation
36091
+ * when the suffix decorations consume too much of the budget for the
36092
+ * path-aware variant to leave a meaningful filename.
36093
+ *
36094
+ * Used by the changed-files list AND the compose-context staged /
36095
+ * unstaged sections so all three places elide identically — same
36096
+ * floor (8 cells), same fallback shape.
36097
+ */
36098
+ function smartPathLabel(prefix, path, suffix, totalBudget) {
36099
+ const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
36100
+ if (pathBudget >= 8) {
36101
+ return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
36102
+ }
36103
+ return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
36104
+ }
35286
36105
  /**
35287
36106
  * Render a list of changed files with status-code colors and stats. Used
35288
36107
  * by both the history inspector and the commit-diff detail panel so the
@@ -35310,13 +36129,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
35310
36129
  // in `lfsPointer.ts` so even rename / mode-only rows are
35311
36130
  // flagged.
35312
36131
  const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
35313
- const label = `${cursor} ${statusCode} ${file.path}${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36132
+ // Smart path truncation via `smartPathLabel`: keeps the cursor +
36133
+ // status-code prefix and the stats/badge suffix intact, gives
36134
+ // the path's remaining width budget to middle-elision so the
36135
+ // filename survives instead of getting blunt-truncated off the
36136
+ // end (the issue users hit when inspector paths read like
36137
+ // `src/commands/log/da...`).
36138
+ const labelPrefix = `${cursor} ${statusCode} `;
36139
+ const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36140
+ const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
35314
36141
  return h(Text, {
35315
36142
  key: `commit-file-${index}`,
35316
36143
  color: statusCodeColor(file.status, theme),
35317
36144
  inverse: isSelected && focused && !theme.noColor,
35318
36145
  bold: isSelected,
35319
- }, truncateCells(label, width - 4));
36146
+ }, label);
35320
36147
  });
35321
36148
  }
35322
36149
  function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
@@ -35592,7 +36419,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35592
36419
  ...stagedFiles.map((file, index) => h(Text, {
35593
36420
  key: `compose-context-staged-${index}`,
35594
36421
  color: theme.noColor ? undefined : theme.colors.gitAdded,
35595
- }, truncateCells(` ${file.indexStatus} ${file.path}`, width - 4))),
36422
+ }, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
35596
36423
  h(Text, { key: 'compose-context-staged-spacer' }, ''),
35597
36424
  ]
35598
36425
  : []), ...(unstagedFiles.length
@@ -35601,7 +36428,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35601
36428
  ...unstagedFiles.map((file, index) => h(Text, {
35602
36429
  key: `compose-context-unstaged-${index}`,
35603
36430
  color: theme.noColor ? undefined : theme.colors.gitModified,
35604
- }, truncateCells(` ${file.worktreeStatus} ${file.path}`, width - 4))),
36431
+ }, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
35605
36432
  ]
35606
36433
  : !stagedFiles.length && !loadingWorktree
35607
36434
  ? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
@@ -36268,6 +37095,14 @@ function LogInkApp(deps) {
36268
37095
  // workdirs for submodule paths recorded in `.gitmodules` (which
36269
37096
  // are repo-relative). Undefined during the brief moment between
36270
37097
  // git swap and the revparse callback resolving.
37098
+ //
37099
+ // Audit finding #10: rapid frame push/pop races are prevented by
37100
+ // the per-effect `cancelled` flag — React fires the cleanup
37101
+ // synchronously BEFORE running the next effect body, so any
37102
+ // pending revparse from the old `git` sees `cancelled === true`
37103
+ // and skips its write. The `git` reference itself is captured by
37104
+ // closure, so each effect run resolves against the right binding.
37105
+ // No additional depth tagging is needed.
36271
37106
  const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
36272
37107
  React.useEffect(() => {
36273
37108
  let cancelled = false;
@@ -36493,7 +37328,7 @@ function LogInkApp(deps) {
36493
37328
  if (cancelled || !mountedRef.current)
36494
37329
  return;
36495
37330
  const message = error instanceof Error ? error.message : String(error);
36496
- dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
37331
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
36497
37332
  dispatch({ type: 'setBootLoading', value: false });
36498
37333
  });
36499
37334
  return () => {
@@ -36561,8 +37396,15 @@ function LogInkApp(deps) {
36561
37396
  ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
36562
37397
  ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
36563
37398
  };
37399
+ // Stash commits as graph roots so post-operation refreshes
37400
+ // keep the same rich graph the boot loader assembled. Without
37401
+ // this, every commit / split-apply / etc. would drop stash
37402
+ // anchors and the cursor-syncs-history effect would degrade
37403
+ // back to "tip not in loaded window" for older stashes.
37404
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
36564
37405
  const fresh = await getLogRows(git, mergedArgv, {
36565
37406
  limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
37407
+ extraRefs: stashHashes,
36566
37408
  });
36567
37409
  if (mountedRef.current && fresh) {
36568
37410
  dispatch({ type: 'replaceRows', rows: fresh });
@@ -36698,18 +37540,50 @@ function LogInkApp(deps) {
36698
37540
  })();
36699
37541
  return () => { cancelled = true; };
36700
37542
  }, [git, dispatch]);
37543
+ // Audit finding #2: re-resolve the repo root inline on every save
37544
+ // and key the deps off `git` + the saved value. The original
37545
+ // implementation read from `repoRootRef.current`, which is async-
37546
+ // populated by the resolver effect above and can lag behind a git
37547
+ // swap. After #995's synchronous pop-restore, the parent's freshly
37548
+ // restored sidebar tab was being written into the submodule's
37549
+ // cache because the ref still held the submodule root during the
37550
+ // brief window before the resolver settled.
37551
+ //
37552
+ // The extra `revparse` cost per save is negligible (saves fire
37553
+ // once per user-initiated tab change, not per render) and the
37554
+ // cancellation flag prevents a stale resolution from racing a
37555
+ // newer one in flight.
36701
37556
  React.useEffect(() => {
36702
- const repoRoot = repoRootRef.current;
36703
- if (!repoRoot)
36704
- return;
36705
- saveSidebarTab(repoRoot, state.userSidebarTab);
36706
- }, [state.userSidebarTab]);
37557
+ let cancelled = false;
37558
+ void (async () => {
37559
+ try {
37560
+ const root = (await git.revparse(['--show-toplevel'])).trim();
37561
+ if (cancelled || !root)
37562
+ return;
37563
+ saveSidebarTab(root, state.userSidebarTab);
37564
+ }
37565
+ catch {
37566
+ // Not in a worktree, or revparse failed — silently skip.
37567
+ // The next save attempt will retry.
37568
+ }
37569
+ })();
37570
+ return () => { cancelled = true; };
37571
+ }, [state.userSidebarTab, git]);
36707
37572
  React.useEffect(() => {
36708
- const repoRoot = repoRootRef.current;
36709
- if (!repoRoot)
36710
- return;
36711
- saveDiffViewMode(repoRoot, state.diffViewMode);
36712
- }, [state.diffViewMode]);
37573
+ let cancelled = false;
37574
+ void (async () => {
37575
+ try {
37576
+ const root = (await git.revparse(['--show-toplevel'])).trim();
37577
+ if (cancelled || !root)
37578
+ return;
37579
+ saveDiffViewMode(root, state.diffViewMode);
37580
+ }
37581
+ catch {
37582
+ // Same as above.
37583
+ }
37584
+ })();
37585
+ return () => { cancelled = true; };
37586
+ }, [state.diffViewMode, git]);
36713
37587
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
36714
37588
  // becomes active with diffSource='stash'. Best-effort — empty stashes
36715
37589
  // or read errors fall through to a "no diff" hint at the render site.
@@ -37096,12 +37970,33 @@ function LogInkApp(deps) {
37096
37970
  // fetched yet); a status hint surfaces in that case so the user
37097
37971
  // knows to toggle full graph or load older commits.
37098
37972
  const lastSyncedHashRef = React.useRef(undefined);
37973
+ // Tracks which target hashes we've already anchored a `git log`
37974
+ // fetch on (#1034 follow-up). When the cursor-syncs-history effect
37975
+ // sees a target whose hash isn't in the loaded window AND isn't in
37976
+ // this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
37977
+ // here. After the fetch resolves and rows are appended, the effect
37978
+ // re-fires; if the target STILL isn't loaded the resolver sees the
37979
+ // hash in this set and returns `unreachable` instead of looping.
37980
+ //
37981
+ // Stored as a ref because (a) the resolver only ever reads it and
37982
+ // (b) component re-renders on state.filteredCommits change are the
37983
+ // re-fire trigger; storing here in state would add a redundant
37984
+ // render per attempt.
37985
+ const attemptedContextHashesRef = React.useRef(new Set());
37986
+ const loadCommitContextRef = React.useRef(null);
37099
37987
  React.useEffect(() => {
37100
37988
  const onBranchTab = state.activeView === 'branches' ||
37101
37989
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37102
37990
  const onTagTab = state.activeView === 'tags' ||
37103
37991
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37104
- if (!onBranchTab && !onTagTab)
37992
+ // User-reported gap: cursoring a stash didn't sync the history
37993
+ // cursor the way cursoring a branch / tag did. Same auto-jump
37994
+ // affordance now extends to stashes; the stash's commit hash IS
37995
+ // the row to land on (stashes are commits living off the
37996
+ // `refs/stash` tree, visible under `--all` / fullGraph).
37997
+ const onStashTab = state.activeView === 'stash' ||
37998
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
37999
+ if (!onBranchTab && !onTagTab && !onStashTab)
37105
38000
  return;
37106
38001
  let targetHash;
37107
38002
  let targetLabel;
@@ -37127,51 +38022,117 @@ function LogInkApp(deps) {
37127
38022
  targetLabel = `tag ${tag.name}`;
37128
38023
  }
37129
38024
  }
37130
- if (!targetHash)
37131
- return;
37132
- // Skip the dispatch + status churn when the cursor hasn't
37133
- // actually changed which commit it's targeting (the case for
37134
- // rapid navigation through a cluster of branches that all point
37135
- // at the same commit). Without this guard the user sees a stream
37136
- // of "Synced history to <branch> tip" status messages even
37137
- // though the history cursor never moved.
37138
- if (targetHash === lastSyncedHashRef.current)
37139
- return;
37140
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
37141
- if (loaded) {
37142
- lastSyncedHashRef.current = targetHash;
37143
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
37144
- // Confirmation status message so the user gets feedback even
37145
- // when the dedicated branches / tags view is occupying the
37146
- // main panel and the history cursor moves invisibly behind it.
37147
- dispatch({
37148
- type: 'setStatus',
37149
- value: `Synced history to ${targetLabel} tip`,
37150
- });
38025
+ else if (onStashTab) {
38026
+ const all = context.stashes?.stashes || [];
38027
+ const visible = state.filter
38028
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
38029
+ : all;
38030
+ const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
38031
+ if (stash) {
38032
+ // Two-step fallback chain for stash cursor sync:
38033
+ //
38034
+ // 1. Try `baseHash` (the branch tip the stash was created
38035
+ // from). This answers the user-visible question "where
38036
+ // in larger git history was this stash made?" — that's
38037
+ // the branch origin point, not the stash's own merge-
38038
+ // commit row off in `refs/stash`. Base commits live on
38039
+ // regular branches so they're almost always in the
38040
+ // loaded window.
38041
+ //
38042
+ // 2. If `baseHash` isn't in the loaded window (the stash's
38043
+ // base branch was deleted, or the base is older than
38044
+ // the 1000-commit cap), fall back to `stash.hash`
38045
+ // itself. The stash commit was added as an extraRef so
38046
+ // it's reachable from the graph if it fits the window.
38047
+ //
38048
+ // Only after BOTH miss does the effect report "tip not in
38049
+ // loaded window." The label flips to mention "base" vs the
38050
+ // stash commit so the user knows what they're looking at.
38051
+ // hashesMatchAny handles the short-hash auto-extension
38052
+ // mismatch between `git stash list --format=%h` (stash hash)
38053
+ // and `git log --pretty=format:%h` (history row). Same
38054
+ // hazard as the branch/tag cursor sync — see src/git/hashes.ts.
38055
+ const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
38056
+ const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
38057
+ if (baseLoaded) {
38058
+ targetHash = stash.baseHash;
38059
+ targetLabel = `${stash.ref}'s base`;
38060
+ }
38061
+ else if (hashLoaded) {
38062
+ targetHash = stash.hash;
38063
+ targetLabel = stash.ref;
38064
+ }
38065
+ else {
38066
+ // Neither in window — set to baseHash so the standard
38067
+ // "not in loaded window" message fires with a meaningful
38068
+ // label (the base is what the user actually wants to see).
38069
+ targetHash = stash.baseHash || stash.hash;
38070
+ targetLabel = stash.ref;
38071
+ }
38072
+ }
37151
38073
  }
37152
- else {
37153
- dispatch({
37154
- type: 'setStatus',
37155
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
37156
- });
38074
+ // Delegate the actual decision to the pure resolver so the
38075
+ // logic is testable in isolation. The effect just performs the
38076
+ // resolver's chosen action.
38077
+ const decision = resolveCursorSyncDecision({
38078
+ target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
38079
+ loadedHashes: buildLoadedHashSet(state.filteredCommits),
38080
+ lastSyncedHash: lastSyncedHashRef.current,
38081
+ attemptedContextHashes: attemptedContextHashesRef.current,
38082
+ });
38083
+ switch (decision.type) {
38084
+ case 'noop':
38085
+ return;
38086
+ case 'jump':
38087
+ lastSyncedHashRef.current = decision.hash;
38088
+ dispatch({ type: 'selectCommitByHash', hash: decision.hash });
38089
+ dispatch({
38090
+ type: 'setStatus',
38091
+ value: `Synced history to ${decision.label} tip`,
38092
+ });
38093
+ return;
38094
+ case 'load-context':
38095
+ // Mark the hash as attempted BEFORE firing the load so a
38096
+ // re-fire of this effect (state.filteredCommits change while
38097
+ // the load is in flight) doesn't kick off a duplicate
38098
+ // request. The resolver sees the hash in the set and
38099
+ // returns `noop` until the load completes; on completion the
38100
+ // appendRows triggers a final re-fire that either jumps or
38101
+ // returns `unreachable`.
38102
+ attemptedContextHashesRef.current.add(decision.target.hash);
38103
+ void loadCommitContextRef.current?.(decision.target);
38104
+ return;
38105
+ case 'unreachable':
38106
+ dispatch({
38107
+ type: 'setStatus',
38108
+ value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
38109
+ kind: 'warning',
38110
+ });
38111
+ return;
37157
38112
  }
37158
38113
  }, [
37159
- dispatch, context.branches, context.tags,
38114
+ dispatch, context.branches, context.tags, context.stashes,
37160
38115
  state.activeView, state.focus, state.sidebarTab,
37161
- state.selectedBranchIndex, state.selectedTagIndex,
38116
+ state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
37162
38117
  state.branchSort, state.tagSort, state.filter,
37163
38118
  state.filteredCommits,
37164
38119
  ]);
37165
38120
  // Reset the dedup ref when the user moves focus away from the
37166
- // sidebar branches / tags tab so re-entering re-fires the sync
37167
- // even if the cursored branch is the same as before.
38121
+ // sidebar branches / tags / stashes tab so re-entering re-fires the
38122
+ // sync even if the cursored row is the same as before.
37168
38123
  React.useEffect(() => {
37169
38124
  const onBranchTab = state.activeView === 'branches' ||
37170
38125
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37171
38126
  const onTagTab = state.activeView === 'tags' ||
37172
38127
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37173
- if (!onBranchTab && !onTagTab) {
38128
+ const onStashTab = state.activeView === 'stash' ||
38129
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
38130
+ if (!onBranchTab && !onTagTab && !onStashTab) {
37174
38131
  lastSyncedHashRef.current = undefined;
38132
+ // Drop any context-load attempt tracking too. If the user
38133
+ // navigates back later we want to retry rather than show
38134
+ // "unreachable" based on a stale attempted-set.
38135
+ attemptedContextHashesRef.current = new Set();
37175
38136
  }
37176
38137
  }, [state.activeView, state.focus, state.sidebarTab]);
37177
38138
  React.useEffect(() => {
@@ -37202,7 +38163,7 @@ function LogInkApp(deps) {
37202
38163
  ]);
37203
38164
  const toggleSelectedFileStage = React.useCallback(async () => {
37204
38165
  if (!selectedWorktreeFile) {
37205
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38166
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37206
38167
  return;
37207
38168
  }
37208
38169
  dispatch({ type: 'setStatus', value: 'updating file stage state' });
@@ -37217,7 +38178,7 @@ function LogInkApp(deps) {
37217
38178
  const toggleSelectedHunkStage = React.useCallback(async () => {
37218
38179
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37219
38180
  if (!selectedHunk) {
37220
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38181
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37221
38182
  return;
37222
38183
  }
37223
38184
  dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
@@ -37231,6 +38192,7 @@ function LogInkApp(deps) {
37231
38192
  dispatch({
37232
38193
  type: 'setStatus',
37233
38194
  value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
38195
+ kind: 'success',
37234
38196
  });
37235
38197
  await refreshWorktreeContext();
37236
38198
  setWorktreeDiff(undefined);
@@ -37240,12 +38202,13 @@ function LogInkApp(deps) {
37240
38202
  dispatch({
37241
38203
  type: 'setStatus',
37242
38204
  value: error.message || 'failed to update hunk stage state',
38205
+ kind: 'error',
37243
38206
  });
37244
38207
  }
37245
38208
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37246
38209
  const revertSelectedFile = React.useCallback(async () => {
37247
38210
  if (!selectedWorktreeFile) {
37248
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38211
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37249
38212
  return;
37250
38213
  }
37251
38214
  dispatch({ type: 'setStatus', value: 'reverting selected file' });
@@ -37258,13 +38221,13 @@ function LogInkApp(deps) {
37258
38221
  const revertSelectedHunk = React.useCallback(async () => {
37259
38222
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37260
38223
  if (!selectedHunk) {
37261
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38224
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37262
38225
  return;
37263
38226
  }
37264
38227
  dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
37265
38228
  try {
37266
38229
  await revertHunk(git, selectedHunk);
37267
- dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}` });
38230
+ dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
37268
38231
  await refreshWorktreeContext();
37269
38232
  setWorktreeDiff(undefined);
37270
38233
  setWorktreeHunks(undefined);
@@ -37273,13 +38236,14 @@ function LogInkApp(deps) {
37273
38236
  dispatch({
37274
38237
  type: 'setStatus',
37275
38238
  value: error.message || 'failed to revert hunk',
38239
+ kind: 'error',
37276
38240
  });
37277
38241
  }
37278
38242
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37279
38243
  const createCommitFromCompose = React.useCallback(async () => {
37280
38244
  const stagedCount = context.worktree?.stagedCount || 0;
37281
38245
  if (!stagedCount) {
37282
- dispatch({ type: 'setStatus', value: 'stage changes before committing' });
38246
+ dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
37283
38247
  return;
37284
38248
  }
37285
38249
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
@@ -37343,6 +38307,12 @@ function LogInkApp(deps) {
37343
38307
  git,
37344
38308
  signal: controller.signal,
37345
38309
  onStreamChunk: (_text, accumulated) => {
38310
+ // Audit finding #4: skip dispatching into a torn-down
38311
+ // tree. If the user quit (or otherwise unmounted the
38312
+ // workstation) mid-stream, React warns about updates on
38313
+ // an unmounted component. Drop the chunk silently.
38314
+ if (!mountedRef.current)
38315
+ return;
37346
38316
  // Dispatch the full accumulated text — the preview chrome
37347
38317
  // helper does the last-N-lines slicing at render time, so
37348
38318
  // re-doing the slice here would be wasted work. Per-chunk
@@ -37354,18 +38324,23 @@ function LogInkApp(deps) {
37354
38324
  });
37355
38325
  },
37356
38326
  });
38327
+ // Audit finding #4 (unmount race): bail out before any
38328
+ // post-await dispatch if the user quit while the LLM call was
38329
+ // in flight. Same pattern as `refreshHistoryRows` upstream.
38330
+ if (!mountedRef.current)
38331
+ return;
37357
38332
  // Cancel path (#881 phase 3). User pressed Esc during the
37358
38333
  // stream; reducer drops loading + preview, status line shows
37359
38334
  // a neutral "cancelled" message. Skip the result / failure
37360
38335
  // dispatches because the user already knows what happened.
37361
38336
  if (result.cancelled) {
37362
38337
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37363
- dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
38338
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
37364
38339
  return;
37365
38340
  }
37366
38341
  if (result.ok && result.draft) {
37367
38342
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37368
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
38343
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
37369
38344
  return;
37370
38345
  }
37371
38346
  dispatch({
@@ -37374,6 +38349,23 @@ function LogInkApp(deps) {
37374
38349
  });
37375
38350
  dispatch({ type: 'setStatus', value: result.message });
37376
38351
  }
38352
+ catch (error) {
38353
+ // Audit finding #3: defensive recovery for unexpected throws
38354
+ // from the workflow. The workflow catches its own errors
38355
+ // today, so this catch is latent — but any future refactor
38356
+ // that lets an error escape would otherwise strand the
38357
+ // spinner permanently with no user-facing recovery short of
38358
+ // quitting. Surface a generic failure and clear the loading
38359
+ // state so the user can re-try.
38360
+ if (mountedRef.current) {
38361
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
38362
+ dispatch({
38363
+ type: 'setStatus',
38364
+ value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
38365
+ kind: 'error',
38366
+ });
38367
+ }
38368
+ }
37377
38369
  finally {
37378
38370
  // Clear the ref only if it still points at OUR controller — a
37379
38371
  // rapid second invocation could have already replaced it, in
@@ -37432,7 +38424,7 @@ function LogInkApp(deps) {
37432
38424
  const startCreatePullRequest = React.useCallback(async () => {
37433
38425
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37434
38426
  if (!head) {
37435
- dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
38427
+ dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
37436
38428
  return;
37437
38429
  }
37438
38430
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37440,11 +38432,12 @@ function LogInkApp(deps) {
37440
38432
  dispatch({
37441
38433
  type: 'setStatus',
37442
38434
  value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
38435
+ kind: 'warning',
37443
38436
  });
37444
38437
  return;
37445
38438
  }
37446
38439
  if (head === defaultBranch) {
37447
- dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.` });
38440
+ dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
37448
38441
  return;
37449
38442
  }
37450
38443
  if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
@@ -37454,6 +38447,7 @@ function LogInkApp(deps) {
37454
38447
  value: existing
37455
38448
  ? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
37456
38449
  : `A pull request is already open for ${head}.`,
38450
+ kind: 'warning',
37457
38451
  });
37458
38452
  return;
37459
38453
  }
@@ -37465,9 +38459,14 @@ function LogInkApp(deps) {
37465
38459
  const cancelHandle = { cancelled: false };
37466
38460
  pullRequestBodyCancelRef.current = cancelHandle;
37467
38461
  dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
38462
+ // Audit finding #6: soft cancel today — Esc skips opening the
38463
+ // follow-up prompt, but the LLM call itself keeps running to
38464
+ // completion (no AbortSignal threaded through the changelog CLI
38465
+ // chain). Status copy reflects that honestly so the user isn't
38466
+ // misled into thinking they're saving tokens.
37468
38467
  dispatch({
37469
38468
  type: 'setStatus',
37470
- value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
38469
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
37471
38470
  loading: true,
37472
38471
  });
37473
38472
  try {
@@ -37489,11 +38488,20 @@ function LogInkApp(deps) {
37489
38488
  const initialBody = body.body || '';
37490
38489
  const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37491
38490
  if (!body.ok) {
37492
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
38491
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
37493
38492
  }
37494
38493
  else {
37495
- dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37496
- }
38494
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
38495
+ }
38496
+ // Audit finding #11: clear the pending flag BEFORE opening the
38497
+ // prompt. If a future refactor adds an `await` between the flag
38498
+ // clear (currently in `finally`) and the `openInputPrompt`
38499
+ // dispatch, an Esc keystroke in the gap would dispatch
38500
+ // `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
38501
+ // the prompt visible with a stale "cancelled" message. Clearing
38502
+ // here moves the flag teardown into the same React batch as the
38503
+ // prompt open, eliminating the race.
38504
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37497
38505
  dispatch({
37498
38506
  type: 'openInputPrompt',
37499
38507
  kind: 'create-pr',
@@ -37503,11 +38511,14 @@ function LogInkApp(deps) {
37503
38511
  });
37504
38512
  }
37505
38513
  finally {
37506
- // Clear the flag + the ref so a subsequent draft starts clean.
38514
+ // Belt-and-suspenders: the `try` block clears the flag on the
38515
+ // success path (audit finding #11). This duplicate clear handles
38516
+ // the error / cancel paths where the early-returns skip the
38517
+ // success-path dispatch. Safe to no-op when already false.
38518
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37507
38519
  // Only clear the ref if we still own it — a second invocation
37508
38520
  // would have already taken ownership in which case the cancel
37509
38521
  // duty has rolled over.
37510
- dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37511
38522
  if (pullRequestBodyCancelRef.current === cancelHandle) {
37512
38523
  pullRequestBodyCancelRef.current = null;
37513
38524
  }
@@ -37547,17 +38558,18 @@ function LogInkApp(deps) {
37547
38558
  const yankText = React.useCallback(async (value, label) => {
37548
38559
  const clipboard = clipboardRunner || defaultClipboardRunner;
37549
38560
  if (!value) {
37550
- dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.` });
38561
+ dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
37551
38562
  return;
37552
38563
  }
37553
38564
  try {
37554
38565
  await clipboard(value);
37555
- dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.` });
38566
+ dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
37556
38567
  }
37557
38568
  catch (error) {
37558
38569
  dispatch({
37559
38570
  type: 'setStatus',
37560
38571
  value: `Copy failed (${label}): ${error.message}`,
38572
+ kind: 'error',
37561
38573
  });
37562
38574
  }
37563
38575
  }, [clipboardRunner, dispatch]);
@@ -37581,7 +38593,7 @@ function LogInkApp(deps) {
37581
38593
  const startChangelogView = React.useCallback(async (options = {}) => {
37582
38594
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37583
38595
  if (!head) {
37584
- dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
38596
+ dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
37585
38597
  return;
37586
38598
  }
37587
38599
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37608,6 +38620,11 @@ function LogInkApp(deps) {
37608
38620
  branch: head,
37609
38621
  baseLabel: cached.baseLabel,
37610
38622
  text: cached.text,
38623
+ // Audit finding #9: cache-hit path preserves the original
38624
+ // generation timestamp rather than minting a fresh one — the
38625
+ // "X ago" header should reflect when the LLM ran, not when
38626
+ // the cached entry was re-displayed.
38627
+ generatedAt: cached.generatedAt,
37611
38628
  });
37612
38629
  dispatch({
37613
38630
  type: 'setStatus',
@@ -37628,7 +38645,7 @@ function LogInkApp(deps) {
37628
38645
  baseLabel,
37629
38646
  error: result.message,
37630
38647
  });
37631
- dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}` });
38648
+ dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
37632
38649
  return;
37633
38650
  }
37634
38651
  dispatch({
@@ -37636,10 +38653,14 @@ function LogInkApp(deps) {
37636
38653
  branch: head,
37637
38654
  baseLabel,
37638
38655
  text: result.text,
38656
+ // Audit finding #9: timestamp captured at dispatch time, not
38657
+ // inside the reducer.
38658
+ generatedAt: Date.now(),
37639
38659
  });
37640
38660
  dispatch({
37641
38661
  type: 'setStatus',
37642
38662
  value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
38663
+ kind: 'success',
37643
38664
  });
37644
38665
  }, [
37645
38666
  context.branches?.currentBranch,
@@ -37660,7 +38681,7 @@ function LogInkApp(deps) {
37660
38681
  const yankChangelog = React.useCallback(() => {
37661
38682
  const text = state.changelogView.text;
37662
38683
  if (!text) {
37663
- dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
38684
+ dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
37664
38685
  return;
37665
38686
  }
37666
38687
  void yankText(text, 'changelog');
@@ -37673,7 +38694,7 @@ function LogInkApp(deps) {
37673
38694
  const openChangelogInEditor = React.useCallback(() => {
37674
38695
  const current = state.changelogView.text;
37675
38696
  if (current === undefined) {
37676
- dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
38697
+ dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
37677
38698
  return;
37678
38699
  }
37679
38700
  let dir;
@@ -37684,6 +38705,7 @@ function LogInkApp(deps) {
37684
38705
  dispatch({
37685
38706
  type: 'setStatus',
37686
38707
  value: `Failed to create temp file for editor: ${error.message}`,
38708
+ kind: 'error',
37687
38709
  });
37688
38710
  return;
37689
38711
  }
@@ -37695,6 +38717,7 @@ function LogInkApp(deps) {
37695
38717
  dispatch({
37696
38718
  type: 'setStatus',
37697
38719
  value: `Failed to seed temp file: ${error.message}`,
38720
+ kind: 'error',
37698
38721
  });
37699
38722
  try {
37700
38723
  fs$1.rmSync(dir, { recursive: true, force: true });
@@ -37718,13 +38741,13 @@ function LogInkApp(deps) {
37718
38741
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37719
38742
  const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
37720
38743
  if (result.error) {
37721
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38744
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37722
38745
  }
37723
38746
  else if (result.signal) {
37724
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38747
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37725
38748
  }
37726
38749
  else if (typeof result.status === 'number' && result.status !== 0) {
37727
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38750
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37728
38751
  }
37729
38752
  else {
37730
38753
  editorOk = true;
@@ -37738,13 +38761,14 @@ function LogInkApp(deps) {
37738
38761
  if (editorOk) {
37739
38762
  try {
37740
38763
  const content = fs$1.readFileSync(file, 'utf8');
37741
- dispatch({ type: 'setChangelogText', text: content });
37742
- dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
38764
+ dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
38765
+ dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
37743
38766
  }
37744
38767
  catch (error) {
37745
38768
  dispatch({
37746
38769
  type: 'setStatus',
37747
38770
  value: `Failed to read back edited changelog: ${error.message}`,
38771
+ kind: 'error',
37748
38772
  });
37749
38773
  }
37750
38774
  }
@@ -37785,19 +38809,19 @@ function LogInkApp(deps) {
37785
38809
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37786
38810
  const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
37787
38811
  if (result.error) {
37788
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38812
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37789
38813
  }
37790
38814
  else if (result.signal) {
37791
38815
  // Editor was killed by a signal (e.g. ^C, SIGTERM). status is
37792
38816
  // null in this case, so the old `status !== 0` check would
37793
38817
  // mistakenly fall through to the success branch.
37794
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38818
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37795
38819
  }
37796
38820
  else if (typeof result.status === 'number' && result.status !== 0) {
37797
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38821
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37798
38822
  }
37799
38823
  else {
37800
- dispatch({ type: 'setStatus', value: `Edited ${path}` });
38824
+ dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
37801
38825
  }
37802
38826
  }
37803
38827
  finally {
@@ -37844,6 +38868,7 @@ function LogInkApp(deps) {
37844
38868
  dispatch({
37845
38869
  type: 'setStatus',
37846
38870
  value: `Failed to create temp file for editor: ${error.message}`,
38871
+ kind: 'error',
37847
38872
  });
37848
38873
  return;
37849
38874
  }
@@ -37855,6 +38880,7 @@ function LogInkApp(deps) {
37855
38880
  dispatch({
37856
38881
  type: 'setStatus',
37857
38882
  value: `Failed to seed temp file: ${error.message}`,
38883
+ kind: 'error',
37858
38884
  });
37859
38885
  try {
37860
38886
  fs$1.rmSync(dir, { recursive: true, force: true });
@@ -37878,13 +38904,13 @@ function LogInkApp(deps) {
37878
38904
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37879
38905
  const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
37880
38906
  if (result.error) {
37881
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38907
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37882
38908
  }
37883
38909
  else if (result.signal) {
37884
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38910
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37885
38911
  }
37886
38912
  else if (typeof result.status === 'number' && result.status !== 0) {
37887
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38913
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37888
38914
  }
37889
38915
  else {
37890
38916
  editorOk = true;
@@ -37903,12 +38929,13 @@ function LogInkApp(deps) {
37903
38929
  try {
37904
38930
  const content = fs$1.readFileSync(file, 'utf8');
37905
38931
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
37906
- dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
38932
+ dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
37907
38933
  }
37908
38934
  catch (error) {
37909
38935
  dispatch({
37910
38936
  type: 'setStatus',
37911
38937
  value: `Failed to read back edited draft: ${error.message}`,
38938
+ kind: 'error',
37912
38939
  });
37913
38940
  }
37914
38941
  }
@@ -37984,7 +39011,7 @@ function LogInkApp(deps) {
37984
39011
  const applyCommitSplit = React.useCallback(async () => {
37985
39012
  const splitPlan = state.splitPlan;
37986
39013
  if (!splitPlan?.plan || !splitPlan.planContext) {
37987
- dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
39014
+ dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
37988
39015
  return;
37989
39016
  }
37990
39017
  // Diagnostic dump for the silent-failure bug surfaced in #944
@@ -38093,7 +39120,8 @@ function LogInkApp(deps) {
38093
39120
  // that could disagree with reality on partial-apply.
38094
39121
  const commitHashes = result.commitHashes || [];
38095
39122
  if (commitHashes.length > 0) {
38096
- dispatch({ type: 'markRecentCommits', hashes: commitHashes });
39123
+ // Audit finding #9: timestamp captured at dispatch time.
39124
+ dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
38097
39125
  // DevSkim: ignore DS172411 — function literal, fixed delay,
38098
39126
  // no caller-supplied data flowing through.
38099
39127
  setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
@@ -38954,7 +39982,7 @@ function LogInkApp(deps) {
38954
39982
  };
38955
39983
  const handler = handlers[id];
38956
39984
  if (!handler) {
38957
- dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired` });
39985
+ dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
38958
39986
  return;
38959
39987
  }
38960
39988
  const result = await handler();
@@ -38999,7 +40027,37 @@ function LogInkApp(deps) {
38999
40027
  // without flickering the surfaces through a 'loading' phase.
39000
40028
  await refreshContext({ silent: true });
39001
40029
  }
39002
- }, [context, dispatch, git, refreshContext, refreshHistoryRows, state.branchSort, state.filter, state.selectedBranchIndex,
40030
+ // Stash workflow follow-up. Two distinct behaviours.
40031
+ //
40032
+ // **apply / pop**: the user brought stashed content back into the
40033
+ // worktree, but the sidebar still has them on the stash view.
40034
+ // Expected next move is "look at what landed in my worktree", so
40035
+ // jump them to history view (where the worktree counts in the
40036
+ // sidebar are visible) AND refresh worktree context explicitly so
40037
+ // the staged / unstaged / untracked numbers reflect the changes.
40038
+ //
40039
+ // **drop**: the silent context refresh above already re-fetched
40040
+ // the stash list, BUT users reported it feeling like nothing
40041
+ // happened. Fix two things: refresh worktree alongside (drops can
40042
+ // affect untracked files when the stash held `-u` state), and
40043
+ // surface the new stash count on the status line so there's
40044
+ // unambiguous feedback that the drop landed and the list shrank.
40045
+ if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
40046
+ dispatch({ type: 'pushView', value: 'history' });
40047
+ await refreshWorktreeContext();
40048
+ }
40049
+ if (result?.ok && id === 'drop-stash') {
40050
+ // Explicit worktree refresh in case the dropped stash carried
40051
+ // untracked-file state that's now collected.
40052
+ await refreshWorktreeContext();
40053
+ // The silent context refresh already replaced `context.stashes`;
40054
+ // reading the count back here would be stale because closures
40055
+ // capture the pre-refresh value. Status message stays generic
40056
+ // ("Dropped stash@{N}") — the visible list shrinking is the
40057
+ // unambiguous signal that the operation landed.
40058
+ }
40059
+ }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
40060
+ state.branchSort, state.filter, state.selectedBranchIndex,
39003
40061
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
39004
40062
  state.statusFilterMask, state.tagSort]);
39005
40063
  // Resolve the active view's "yank target" (commit hash / branch /
@@ -39161,15 +40219,15 @@ function LogInkApp(deps) {
39161
40219
  }
39162
40220
  }
39163
40221
  if (!value || !label) {
39164
- dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
40222
+ dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
39165
40223
  return;
39166
40224
  }
39167
40225
  try {
39168
40226
  await clipboard(value);
39169
- dispatch({ type: 'setStatus', value: `Copied ${label}` });
40227
+ dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
39170
40228
  }
39171
40229
  catch (error) {
39172
- dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}` });
40230
+ dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
39173
40231
  }
39174
40232
  }, [
39175
40233
  clipboardRunner,
@@ -39229,63 +40287,175 @@ function LogInkApp(deps) {
39229
40287
  React.useEffect(() => {
39230
40288
  loadingMoreCommitsRef.current = loadingMoreCommits;
39231
40289
  }, [loadingMoreCommits]);
40290
+ // STABLE useCallback (empty deps) for loadMoreCommits. The function
40291
+ // reads the volatile state (commit counts, fetch args, hasMore) via
40292
+ // refs that update on every render so the identity stays constant.
40293
+ //
40294
+ // Why stable matters: the cursor-syncs-history auto-load chain
40295
+ // calls this through a forward-reference ref (loadMoreCommitsRef).
40296
+ // If loadMoreCommits regenerated on every render — as the previous
40297
+ // implementation did via state deps — there was a render-order
40298
+ // race: the cursor sync effect would call the PREVIOUS render's
40299
+ // callback (still in the ref because the ref-setter useEffect runs
40300
+ // after the cursor-sync effect in declaration order), which had
40301
+ // captured a stale `state.commits.length` and re-fetched the same
40302
+ // window. The auto-load chain appeared to fire but never advanced
40303
+ // through history.
40304
+ //
40305
+ // Stable identity + refs sidesteps the race entirely: the function
40306
+ // never changes, and every call reads the latest state.
40307
+ const loadMoreStateRef = React.useRef({
40308
+ commitsLength: state.commits.length,
40309
+ filteredCommitsLength: state.filteredCommits.length,
40310
+ historyFetchArgs: state.historyFetchArgs,
40311
+ hasMoreCommits,
40312
+ logArgv,
40313
+ });
40314
+ loadMoreStateRef.current = {
40315
+ commitsLength: state.commits.length,
40316
+ filteredCommitsLength: state.filteredCommits.length,
40317
+ historyFetchArgs: state.historyFetchArgs,
40318
+ hasMoreCommits,
40319
+ logArgv,
40320
+ };
40321
+ const loadMoreCommits = React.useCallback(async (options = {}) => {
40322
+ const snap = loadMoreStateRef.current;
40323
+ if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
40324
+ return { fired: false, addedCommits: 0 };
40325
+ }
40326
+ if (snap.filteredCommitsLength === 0) {
40327
+ return { fired: false, addedCommits: 0 };
40328
+ }
40329
+ loadingMoreCommitsRef.current = true;
40330
+ const requestId = loadMoreRequestRef.current + 1;
40331
+ loadMoreRequestRef.current = requestId;
40332
+ setLoadingMoreCommits(true);
40333
+ dispatch({
40334
+ type: 'setStatus',
40335
+ value: options.statusMessage || 'loading older commits',
40336
+ loading: true,
40337
+ });
40338
+ const fetchArgs = snap.historyFetchArgs;
40339
+ const mergedArgv = {
40340
+ ...snap.logArgv,
40341
+ ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
40342
+ ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
40343
+ };
40344
+ // Load-more paths a fresh page from git AFTER what's already
40345
+ // loaded; pass the stash hashes again so the additional rows
40346
+ // stay graph-consistent with the boot fetch (a window that
40347
+ // dropped stashes mid-stream would render with broken junctions).
40348
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40349
+ const nextRows = await safe(getLogRows(git, mergedArgv, {
40350
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40351
+ skip: snap.commitsLength,
40352
+ extraRefs: stashHashes,
40353
+ }));
40354
+ if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
40355
+ return { fired: false, addedCommits: 0 };
40356
+ }
40357
+ loadingMoreCommitsRef.current = false;
40358
+ setLoadingMoreCommits(false);
40359
+ const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
40360
+ if (!nextRows) {
40361
+ dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
40362
+ return { fired: false, addedCommits: 0 };
40363
+ }
40364
+ if (nextRows?.length) {
40365
+ dispatch({ type: 'appendRows', rows: nextRows });
40366
+ }
40367
+ setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40368
+ return { fired: true, addedCommits: nextCommitCount };
40369
+ // Empty deps — the function is intentionally stable. State is
40370
+ // read via `loadMoreStateRef.current` at call time, and `dispatch`
40371
+ // / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
40372
+ // already stable across renders by React's contract.
40373
+ }, [dispatch, git]);
40374
+ // Scroll-near-bottom auto-trigger. Fires when the user's cursor is
40375
+ // within 20 rows of the last loaded commit so older history is
40376
+ // already on its way by the time they reach the bottom.
39232
40377
  React.useEffect(() => {
39233
40378
  const remaining = state.filteredCommits.length - state.selectedIndex - 1;
39234
- async function loadMoreCommits() {
39235
- if (!logArgv || logArgv.limit || loadingMoreCommitsRef.current || !hasMoreCommits) {
39236
- return;
39237
- }
39238
- if (state.filteredCommits.length === 0 || remaining > 20) {
39239
- return;
39240
- }
39241
- loadingMoreCommitsRef.current = true;
39242
- const requestId = loadMoreRequestRef.current + 1;
39243
- loadMoreRequestRef.current = requestId;
39244
- setLoadingMoreCommits(true);
39245
- dispatch({ type: 'setStatus', value: 'loading older commits' });
39246
- const fetchArgs = state.historyFetchArgs;
39247
- const mergedArgv = {
39248
- ...logArgv,
39249
- ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
39250
- ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
39251
- };
39252
- const nextRows = await safe(getLogRows(git, mergedArgv, {
39253
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
39254
- skip: state.commits.length,
39255
- }));
39256
- if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
39257
- return;
39258
- }
39259
- loadingMoreCommitsRef.current = false;
39260
- setLoadingMoreCommits(false);
39261
- const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
39262
- if (!nextRows) {
39263
- dispatch({ type: 'setStatus', value: 'failed to load older commits' });
39264
- return;
39265
- }
39266
- if (nextRows?.length) {
39267
- dispatch({ type: 'appendRows', rows: nextRows });
40379
+ if (remaining > 20)
40380
+ return;
40381
+ void loadMoreCommits().then((result) => {
40382
+ if (result.fired) {
40383
+ dispatch({
40384
+ type: 'setStatus',
40385
+ value: result.addedCommits
40386
+ ? `loaded ${result.addedCommits} older commits`
40387
+ : 'end of history',
40388
+ });
39268
40389
  }
39269
- setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
39270
- dispatch({
39271
- type: 'setStatus',
39272
- value: nextCommitCount
39273
- ? `loaded ${nextCommitCount} older commits`
39274
- : 'end of history',
39275
- });
39276
- }
39277
- void loadMoreCommits();
40390
+ });
39278
40391
  }, [
39279
40392
  dispatch,
39280
- git,
39281
- hasMoreCommits,
39282
- loadingMoreCommits,
39283
- logArgv,
39284
- state.commits.length,
40393
+ loadMoreCommits,
39285
40394
  state.filteredCommits.length,
39286
- state.historyFetchArgs,
39287
40395
  state.selectedIndex,
39288
40396
  ]);
40397
+ /**
40398
+ * Targeted-context loader for the cursor-syncs-history effect. Called
40399
+ * when the resolver returns `load-context` — the user cursored a
40400
+ * branch / tag / stash whose target commit isn't in the loaded
40401
+ * window, so we run a `git log` anchored on that commit (guaranteed
40402
+ * to include it) and merge the result via `appendRows` (which
40403
+ * already deduplicates by hash).
40404
+ *
40405
+ * Stable identity (empty deps) for the same reason as
40406
+ * `loadMoreCommits` — the cursor-sync effect calls this through a
40407
+ * forward-reference ref, and a regenerating callback would
40408
+ * reintroduce the render-order race that bit the previous chain.
40409
+ * All volatile state (logArgv, mostly) is read via refs.
40410
+ */
40411
+ const loadCommitContextStateRef = React.useRef({ logArgv });
40412
+ loadCommitContextStateRef.current = { logArgv };
40413
+ const loadCommitContext = React.useCallback(async (target) => {
40414
+ const snap = loadCommitContextStateRef.current;
40415
+ if (!snap.logArgv)
40416
+ return;
40417
+ dispatch({
40418
+ type: 'setStatus',
40419
+ value: `Loading commits around ${target.label}…`,
40420
+ loading: true,
40421
+ });
40422
+ try {
40423
+ // No stashHashes here — `getLogRowsAnchoredOn` walks only from
40424
+ // the target so it can guarantee the target's inclusion.
40425
+ // Stashes are already in the loaded graph from boot's
40426
+ // `loadRowsWithStashes`; `appendRows` deduplicates by hash so
40427
+ // the merged result keeps both views without double-counting.
40428
+ const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
40429
+ if (!mountedRef.current)
40430
+ return;
40431
+ if (rows.length > 0) {
40432
+ dispatch({ type: 'appendRows', rows });
40433
+ // Don't dispatch a setStatus here — the cursor-sync effect
40434
+ // will re-fire on the appendRows-driven filteredCommits
40435
+ // change and either jump (success) or report unreachable
40436
+ // (failure), surfacing the right message.
40437
+ }
40438
+ else {
40439
+ dispatch({
40440
+ type: 'setStatus',
40441
+ value: `${target.label} target commit returned no rows — orphan ref?`,
40442
+ kind: 'warning',
40443
+ });
40444
+ }
40445
+ }
40446
+ catch (error) {
40447
+ if (mountedRef.current) {
40448
+ dispatch({
40449
+ type: 'setStatus',
40450
+ value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
40451
+ kind: 'error',
40452
+ });
40453
+ }
40454
+ }
40455
+ }, [dispatch, git]);
40456
+ React.useEffect(() => {
40457
+ loadCommitContextRef.current = loadCommitContext;
40458
+ }, [loadCommitContext]);
39289
40459
  // Server-side history filter (#776). When the user submits `path:foo`
39290
40460
  // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
39291
40461
  // this effect picks up the change, re-runs `getLogRows` with merged
@@ -39321,12 +40491,16 @@ function LogInkApp(deps) {
39321
40491
  value: description ? `Refetching with ${description}` : 'Restoring full log',
39322
40492
  });
39323
40493
  void (async () => {
39324
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40494
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40495
+ const nextRows = await safe(getLogRows(git, merged, {
40496
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40497
+ extraRefs: stashHashes,
40498
+ }));
39325
40499
  if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
39326
40500
  return;
39327
40501
  }
39328
40502
  if (!nextRows) {
39329
- dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
40503
+ dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
39330
40504
  return;
39331
40505
  }
39332
40506
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39337,6 +40511,7 @@ function LogInkApp(deps) {
39337
40511
  value: description
39338
40512
  ? `Showing ${matched} commits matching ${description}`
39339
40513
  : 'Showing full log',
40514
+ kind: 'success',
39340
40515
  });
39341
40516
  })();
39342
40517
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
@@ -39366,12 +40541,20 @@ function LogInkApp(deps) {
39366
40541
  : 'Loading compact history…',
39367
40542
  });
39368
40543
  void (async () => {
39369
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40544
+ // Include stash commits as graph roots so the toggle's re-fetch
40545
+ // sees the same rich graph the boot loader assembles. Without
40546
+ // this, flipping `\` into full mode and back loses the stash
40547
+ // anchors that loadRowsWithStashes seeded on boot.
40548
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40549
+ const nextRows = await safe(getLogRows(git, merged, {
40550
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40551
+ extraRefs: stashHashes,
40552
+ }));
39370
40553
  if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
39371
40554
  return;
39372
40555
  }
39373
40556
  if (!nextRows) {
39374
- dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
40557
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
39375
40558
  return;
39376
40559
  }
39377
40560
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39382,6 +40565,7 @@ function LogInkApp(deps) {
39382
40565
  value: state.fullGraph
39383
40566
  ? `Showing ${matched} commits across all branches`
39384
40567
  : `Showing ${matched} commits (compact)`,
40568
+ kind: 'success',
39385
40569
  });
39386
40570
  })();
39387
40571
  }, [dispatch, git, logArgv, state.fullGraph]);
@@ -40076,6 +41260,17 @@ function createLogArgvFromUiArgv(argv) {
40076
41260
  return {
40077
41261
  $0: argv.$0,
40078
41262
  _: ['log'],
41263
+ // Pass `--all` through from the CLI. The yargs default is `true`
41264
+ // since 0.54.x — user feedback consistently asked for the
41265
+ // GitKraken-style "see all branches, tags, stashes" view as the
41266
+ // starting state. `coco ui --no-all` opts back to
41267
+ // current-branch-only.
41268
+ //
41269
+ // Note: passing `--branch foo` does NOT automatically scope away
41270
+ // from --all. If the user wants strictly that branch, they pass
41271
+ // `coco ui --branch foo --no-all`. We considered the implicit
41272
+ // scope-narrowing but it surprises users who pass `--branch` as
41273
+ // a "highlight this branch in the all-refs view" hint.
40079
41274
  all: argv.all,
40080
41275
  branch: argv.branch,
40081
41276
  format: 'table',
@@ -40112,6 +41307,26 @@ function withCacheWrite(repoPath, loader) {
40112
41307
  return rows;
40113
41308
  };
40114
41309
  }
41310
+ /**
41311
+ * Workstation-aware log loader (#1034 follow-up). Calls `git stash
41312
+ * list` first to collect every stash's commit hash, then passes them
41313
+ * as extra refs to `getLogRows` so the graph includes every stash as
41314
+ * a node — not just the latest (which is the only one `refs/stash`
41315
+ * points at and the only one `git log --all` walks).
41316
+ *
41317
+ * Without this, the stash → history cursor sync added in #1034 only
41318
+ * worked for `stash@{0}`; cursoring any older stash row reported
41319
+ * "tip not in loaded window" because that stash's commit hash was
41320
+ * never in the loaded graph window in the first place.
41321
+ *
41322
+ * The extra git call is cheap (one `git stash list --format=%H`,
41323
+ * usually sub-50ms). It's only an additive cost when stashes exist;
41324
+ * users on stash-free repos pay nothing.
41325
+ */
41326
+ async function loadRowsWithStashes(git, logArgv) {
41327
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
41328
+ return getLogRows(git, logArgv, { extraRefs: stashHashes });
41329
+ }
40115
41330
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
40116
41331
  const config = options.config || loadConfig(logArgv);
40117
41332
  const git = options.git || getRepo();
@@ -40130,7 +41345,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
40130
41345
  const initialRows = options.rows || cachedRows || [];
40131
41346
  const loadRows = options.rows
40132
41347
  ? undefined
40133
- : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
41348
+ : withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
40134
41349
  await startInkInteractiveLog(git, initialRows, {}, {
40135
41350
  appLabel: 'coco',
40136
41351
  idleTips: config.logTui?.idleTips,
@@ -40158,7 +41373,7 @@ async function startCocoUi(argv) {
40158
41373
  idleTips: config.logTui?.idleTips,
40159
41374
  dateBucketing: config.logTui?.dateBucketing,
40160
41375
  initialView: argv.view || 'history',
40161
- loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
41376
+ loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
40162
41377
  logArgv,
40163
41378
  theme: createUiTheme(config, argv),
40164
41379
  });
@@ -41361,9 +42576,9 @@ const options = {
41361
42576
  default: 'history',
41362
42577
  },
41363
42578
  all: {
41364
- description: 'Load commits from all local and remote refs in history mode',
42579
+ description: 'Load commits from all local and remote refs in history mode. Defaults to true so the history view shows the full multi-ref graph (branches, tags, stashes) out of the box; pass `--no-all` to scope to the current branch only.',
41365
42580
  type: 'boolean',
41366
- default: false,
42581
+ default: true,
41367
42582
  },
41368
42583
  branch: {
41369
42584
  description: 'Load history reachable from a branch or ref',