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
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.54.0";
64
+ const BUILD_VERSION = "0.55.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -18351,7 +18351,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
18351
18351
  const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
18352
18352
  const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
18353
18353
  const LOG_DEFAULT_LIMIT = 30;
18354
- const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
18354
+ // Bumped from 300 → 1000 in 0.54.2. With the full-graph default
18355
+ // (#1034) the workstation surfaces many more refs (all branches, all
18356
+ // tags, plus stash commits added via `extraRefs`), and on active repos
18357
+ // the 300-commit cap was cutting off year+-old stash bases and old
18358
+ // tag commits — making the cursor-syncs-history effect report "tip
18359
+ // not in loaded window" instead of moving the graph cursor. 1000
18360
+ // fits a year of activity for most repos, git log is still sub-200ms,
18361
+ // and Ink virtualises scroll so render cost stays flat.
18362
+ const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
18355
18363
  function toArray(value) {
18356
18364
  if (!value) {
18357
18365
  return [];
@@ -18531,12 +18539,77 @@ function buildLogArgs(argv, options = {}) {
18531
18539
  else if (argv.branch) {
18532
18540
  args.push(argv.branch);
18533
18541
  }
18542
+ // Extra refs (stash commits etc.) — append after the --all / branch
18543
+ // selector but BEFORE the path separator. Git treats them as
18544
+ // additional graph roots, so the traversal includes them alongside
18545
+ // whatever --all / --branch already covers.
18546
+ if (options.extraRefs && options.extraRefs.length > 0) {
18547
+ args.push(...options.extraRefs);
18548
+ }
18534
18549
  const paths = toArray(argv.path);
18535
18550
  if (paths.length > 0) {
18536
18551
  args.push('--', ...paths);
18537
18552
  }
18538
18553
  return args;
18539
18554
  }
18555
+ /**
18556
+ * Default size of a targeted-context window. Sized to comfortably
18557
+ * cover a year of activity on most repos so the cursor-sync's
18558
+ * "jump to commit anchored on a ref I just selected" can succeed
18559
+ * without paginating through the whole history.
18560
+ */
18561
+ const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
18562
+ /**
18563
+ * Load a window of commits anchored on a specific hash. Used by the
18564
+ * cursor-sync effect when the user selects a ref (branch / tag /
18565
+ * stash) whose target commit isn't in the loaded graph window.
18566
+ *
18567
+ * Critical detail: this walks **only from the target** (and its
18568
+ * ancestors), NOT from `--all`. Why: when you combine `--all` with
18569
+ * `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
18570
+ * the result by date, and slices the newest N rows. If the target
18571
+ * is older than the Nth newest commit across all refs (very common
18572
+ * for year-old tags / branches on active repos), it falls off the
18573
+ * slice even though it was passed as a root. Walking from the
18574
+ * target alone guarantees the target IS the first row of the
18575
+ * output and its ancestors fill the rest.
18576
+ *
18577
+ * The caller merges the result via the `appendRows` reducer action
18578
+ * which deduplicates by hash, so the target's ancestry slots into
18579
+ * the existing `--all` graph cleanly. The user's loaded view ends
18580
+ * up as the union of: the original `--all` window + target's
18581
+ * ancestry — exactly what's needed for the cursor to land.
18582
+ *
18583
+ * Capped at `options.limit` (default 5000) to keep one targeted
18584
+ * fetch bounded. For most refs, even a 100-commit limit would be
18585
+ * enough to surface the target; we go higher to also pull in the
18586
+ * surrounding context so the user can scroll around the landed
18587
+ * cursor.
18588
+ */
18589
+ async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
18590
+ // Strip every "walk many refs" toggle so buildLogArgs produces a
18591
+ // clean `git log <flags> <targetHash>` — exactly the walk that
18592
+ // guarantees the target's inclusion.
18593
+ const merged = {
18594
+ ...argv,
18595
+ all: false,
18596
+ view: 'compact', // suppresses 'full' → '--all' mapping
18597
+ branch: undefined,
18598
+ path: undefined,
18599
+ };
18600
+ // Also drop --first-parent / --no-merges so the target's ancestry
18601
+ // renders with full topology (matters for stash commits which are
18602
+ // merges by construction).
18603
+ const baseArgs = buildLogArgs(merged, {
18604
+ limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
18605
+ }).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
18606
+ // Splice the target as the positional ref. `buildLogArgs` already
18607
+ // appended any `--all`/`--branch`/`<extraRefs>` it considered;
18608
+ // since we cleared all those above, the only positional ref we
18609
+ // add is the target.
18610
+ baseArgs.push(targetHash);
18611
+ return parseLogOutput(await git.raw(baseArgs));
18612
+ }
18540
18613
  /**
18541
18614
  * Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
18542
18615
  *
@@ -18644,6 +18717,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18644
18717
  };
18645
18718
  }
18646
18719
 
18720
+ function parseStashSubject(subject) {
18721
+ const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18722
+ if (!match) {
18723
+ return {
18724
+ branch: '<unknown>',
18725
+ message: subject,
18726
+ };
18727
+ }
18728
+ return {
18729
+ branch: match[1],
18730
+ message: match[2] || subject,
18731
+ };
18732
+ }
18733
+ function parseStashList(output) {
18734
+ return output
18735
+ .split('\n')
18736
+ .map((line) => line.trim())
18737
+ .filter(Boolean)
18738
+ .map((line) => {
18739
+ const [ref, hash, parents, date, subject] = line.split('\x1f');
18740
+ const parsedSubject = parseStashSubject(subject || '');
18741
+ // `%P` returns space-separated parent hashes. Stash commits are
18742
+ // merges with 2-3 parents; the FIRST is the base (HEAD at stash
18743
+ // time). Empty parents string (legacy / corrupted entries) maps
18744
+ // to an empty baseHash; the cursor-sync caller treats that as
18745
+ // "no base available, fall back to stash hash."
18746
+ const baseHash = parents ? (parents.split(' ')[0] || '') : '';
18747
+ return {
18748
+ ref,
18749
+ hash,
18750
+ baseHash,
18751
+ date,
18752
+ branch: parsedSubject.branch,
18753
+ message: parsedSubject.message,
18754
+ };
18755
+ });
18756
+ }
18757
+ function parseStashFiles(output) {
18758
+ return output
18759
+ .split('\n')
18760
+ .map((line) => line.trim())
18761
+ .filter(Boolean);
18762
+ }
18763
+ /**
18764
+ * Resolve the commit hashes for every stash, in `stash@{N}` order.
18765
+ *
18766
+ * Used by the workstation's history loader to include older stashes
18767
+ * as graph roots — `git log --all` only walks `refs/stash` (the
18768
+ * latest stash) by default, so stash@{1+} commits live off-graph
18769
+ * unless explicitly referenced. Passing this list as positional refs
18770
+ * to `git log` makes every stash appear as a graph node, which lets
18771
+ * the cursor-syncs-history effect actually land on them when the
18772
+ * user navigates the stashes sidebar.
18773
+ *
18774
+ * Cheap: one `git stash list` call, no per-stash fan-out. Returns
18775
+ * an empty array when there are no stashes — callers can pass the
18776
+ * result through unconditionally.
18777
+ */
18778
+ async function getStashCommitHashes(git) {
18779
+ const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
18780
+ return raw
18781
+ .split('\n')
18782
+ .map((line) => line.trim())
18783
+ .filter(Boolean);
18784
+ }
18785
+ async function getStashOverview(git) {
18786
+ // Format fields (separated by 0x1f / unit separator):
18787
+ // %gd — stash reflog selector (stash@{N})
18788
+ // %H — stash commit hash
18789
+ // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18790
+ // %ci — committer date, ISO format
18791
+ // %gs — reflog subject ("WIP on main: <subject>")
18792
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
18793
+ return {
18794
+ stashes: await Promise.all(stashes.map(async (stash) => ({
18795
+ ...stash,
18796
+ files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18797
+ }))),
18798
+ };
18799
+ }
18800
+ /**
18801
+ * Full unified-patch diff for a stash. Used by the diff surface when
18802
+ * `state.diffSource === 'stash'` to render the stash's changes inline.
18803
+ *
18804
+ * Empty stashes (e.g. created by `git stash --keep-index` against an
18805
+ * already-clean tree) return [] rather than throwing — surfaces fall
18806
+ * back to a "no diff to display" message.
18807
+ */
18808
+ async function getStashDiff(git, stashRef) {
18809
+ return (await git.raw(['stash', 'show', '-p', stashRef]))
18810
+ .split('\n')
18811
+ .map((line) => line.replace(/\r$/, ''));
18812
+ }
18813
+ /**
18814
+ * Slice a unified-patch into per-file sections. Each entry records the
18815
+ * file path and the offset of its `diff --git` header within `lines`.
18816
+ * Used by the stash explorer to build a per-file cursor + cherry-pick
18817
+ * the file at the cursor.
18818
+ *
18819
+ * Renames / moves return the destination path (the `b/` side); the
18820
+ * action surface treats that as the path to materialize from the stash.
18821
+ *
18822
+ * Path quoting: git wraps paths containing spaces or special characters
18823
+ * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18824
+ * The parser handles both the unquoted and quoted forms; without that,
18825
+ * stash-file navigation and cherry-pick silently broke for any file
18826
+ * whose path contained a space.
18827
+ */
18828
+ function parseStashDiffFiles(lines) {
18829
+ const files = [];
18830
+ for (let i = 0; i < lines.length; i += 1) {
18831
+ const line = lines[i];
18832
+ const parsed = parseDiffGitHeader(line);
18833
+ if (parsed) {
18834
+ files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18835
+ }
18836
+ }
18837
+ return files;
18838
+ }
18839
+ /**
18840
+ * Resolve which stash file *contains* a given line offset — the user's
18841
+ * cursor scrolls through a concatenated multi-file patch, and this is
18842
+ * what powers the "File N/M: <path>" panel header, the inline header
18843
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18844
+ * dispatchers' "what file is the cursor on" lookup.
18845
+ *
18846
+ * Returns `undefined` when the file list is empty *or* the offset
18847
+ * lands before the very first file's `diff --git` header (e.g. when
18848
+ * `--stat` summary lines lead the patch). Callers fall through to a
18849
+ * "no file selected" state in that case.
18850
+ */
18851
+ function findStashFileForOffset(files, offset) {
18852
+ if (files.length === 0)
18853
+ return undefined;
18854
+ let current;
18855
+ for (const file of files) {
18856
+ if (file.startLine <= offset) {
18857
+ current = file;
18858
+ }
18859
+ else {
18860
+ break;
18861
+ }
18862
+ }
18863
+ // First file is the canonical fallback — even if the offset lands
18864
+ // before its header (rare), we want the cursor to be "in" something
18865
+ // so the user's actions have a target.
18866
+ return current ?? files[0];
18867
+ }
18868
+ const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18869
+ function parseDiffGitHeader(line) {
18870
+ const match = line.match(DIFF_GIT_HEADER);
18871
+ if (!match)
18872
+ return undefined;
18873
+ const aPath = unescapeGitQuoted(match[1]) || match[2];
18874
+ const bPath = unescapeGitQuoted(match[3]) || match[4];
18875
+ if (!aPath || !bPath)
18876
+ return undefined;
18877
+ return { aPath, bPath };
18878
+ }
18879
+ function unescapeGitQuoted(value) {
18880
+ if (value === undefined)
18881
+ return undefined;
18882
+ // Git's diff header quoting escapes `"`, `\`, and the usual
18883
+ // C-style sequences. Reverse the most common ones so callers get the
18884
+ // raw on-disk path.
18885
+ return value
18886
+ .replace(/\\\\/g, '\\')
18887
+ .replace(/\\"/g, '"')
18888
+ .replace(/\\t/g, '\t')
18889
+ .replace(/\\n/g, '\n');
18890
+ }
18891
+
18647
18892
  const FIELD_SEPARATOR$2 = '\x1f';
18648
18893
  function parseBranchRefs(output) {
18649
18894
  return output
@@ -18836,143 +19081,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18836
19081
  }
18837
19082
  }
18838
19083
 
18839
- function parseStashSubject(subject) {
18840
- const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
18841
- if (!match) {
18842
- return {
18843
- branch: '<unknown>',
18844
- message: subject,
18845
- };
18846
- }
18847
- return {
18848
- branch: match[1],
18849
- message: match[2] || subject,
18850
- };
18851
- }
18852
- function parseStashList(output) {
18853
- return output
18854
- .split('\n')
18855
- .map((line) => line.trim())
18856
- .filter(Boolean)
18857
- .map((line) => {
18858
- const [ref, hash, date, subject] = line.split('\x1f');
18859
- const parsedSubject = parseStashSubject(subject || '');
18860
- return {
18861
- ref,
18862
- hash,
18863
- date,
18864
- branch: parsedSubject.branch,
18865
- message: parsedSubject.message,
18866
- };
18867
- });
18868
- }
18869
- function parseStashFiles(output) {
18870
- return output
18871
- .split('\n')
18872
- .map((line) => line.trim())
18873
- .filter(Boolean);
18874
- }
18875
- async function getStashOverview(git) {
18876
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
18877
- return {
18878
- stashes: await Promise.all(stashes.map(async (stash) => ({
18879
- ...stash,
18880
- files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
18881
- }))),
18882
- };
18883
- }
18884
- /**
18885
- * Full unified-patch diff for a stash. Used by the diff surface when
18886
- * `state.diffSource === 'stash'` to render the stash's changes inline.
18887
- *
18888
- * Empty stashes (e.g. created by `git stash --keep-index` against an
18889
- * already-clean tree) return [] rather than throwing — surfaces fall
18890
- * back to a "no diff to display" message.
18891
- */
18892
- async function getStashDiff(git, stashRef) {
18893
- return (await git.raw(['stash', 'show', '-p', stashRef]))
18894
- .split('\n')
18895
- .map((line) => line.replace(/\r$/, ''));
18896
- }
18897
- /**
18898
- * Slice a unified-patch into per-file sections. Each entry records the
18899
- * file path and the offset of its `diff --git` header within `lines`.
18900
- * Used by the stash explorer to build a per-file cursor + cherry-pick
18901
- * the file at the cursor.
18902
- *
18903
- * Renames / moves return the destination path (the `b/` side); the
18904
- * action surface treats that as the path to materialize from the stash.
18905
- *
18906
- * Path quoting: git wraps paths containing spaces or special characters
18907
- * in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
18908
- * The parser handles both the unquoted and quoted forms; without that,
18909
- * stash-file navigation and cherry-pick silently broke for any file
18910
- * whose path contained a space.
18911
- */
18912
- function parseStashDiffFiles(lines) {
18913
- const files = [];
18914
- for (let i = 0; i < lines.length; i += 1) {
18915
- const line = lines[i];
18916
- const parsed = parseDiffGitHeader(line);
18917
- if (parsed) {
18918
- files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
18919
- }
18920
- }
18921
- return files;
18922
- }
18923
- /**
18924
- * Resolve which stash file *contains* a given line offset — the user's
18925
- * cursor scrolls through a concatenated multi-file patch, and this is
18926
- * what powers the "File N/M: <path>" panel header, the inline header
18927
- * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
18928
- * dispatchers' "what file is the cursor on" lookup.
18929
- *
18930
- * Returns `undefined` when the file list is empty *or* the offset
18931
- * lands before the very first file's `diff --git` header (e.g. when
18932
- * `--stat` summary lines lead the patch). Callers fall through to a
18933
- * "no file selected" state in that case.
18934
- */
18935
- function findStashFileForOffset(files, offset) {
18936
- if (files.length === 0)
18937
- return undefined;
18938
- let current;
18939
- for (const file of files) {
18940
- if (file.startLine <= offset) {
18941
- current = file;
18942
- }
18943
- else {
18944
- break;
18945
- }
18946
- }
18947
- // First file is the canonical fallback — even if the offset lands
18948
- // before its header (rare), we want the cursor to be "in" something
18949
- // so the user's actions have a target.
18950
- return current ?? files[0];
18951
- }
18952
- const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
18953
- function parseDiffGitHeader(line) {
18954
- const match = line.match(DIFF_GIT_HEADER);
18955
- if (!match)
18956
- return undefined;
18957
- const aPath = unescapeGitQuoted(match[1]) || match[2];
18958
- const bPath = unescapeGitQuoted(match[3]) || match[4];
18959
- if (!aPath || !bPath)
18960
- return undefined;
18961
- return { aPath, bPath };
18962
- }
18963
- function unescapeGitQuoted(value) {
18964
- if (value === undefined)
18965
- return undefined;
18966
- // Git's diff header quoting escapes `"`, `\`, and the usual
18967
- // C-style sequences. Reverse the most common ones so callers get the
18968
- // raw on-disk path.
18969
- return value
18970
- .replace(/\\\\/g, '\\')
18971
- .replace(/\\"/g, '"')
18972
- .replace(/\\t/g, '\t')
18973
- .replace(/\\n/g, '\n');
18974
- }
18975
-
18976
19084
  function fileState(indexStatus, worktreeStatus) {
18977
19085
  if (indexStatus === '?' && worktreeStatus === '?') {
18978
19086
  return 'untracked';
@@ -19039,7 +19147,18 @@ function parseTagRefs(output) {
19039
19147
  .map((line) => line.trimEnd())
19040
19148
  .filter(Boolean)
19041
19149
  .map((line) => {
19042
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
19150
+ const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
19151
+ // For annotated tags `%(objectname:short)` returns the TAG
19152
+ // OBJECT's SHA, not the commit it points to — that's the SHA
19153
+ // sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
19154
+ // dereferences the tag and yields the commit's SHA, but is
19155
+ // EMPTY for lightweight tags (which are already direct
19156
+ // pointers to commits). Prefer the dereferenced form when
19157
+ // present, fall back to the object SHA otherwise. This is what
19158
+ // lets cursor-sync find the tagged commit in the loaded log
19159
+ // window — anchoring on the tag object's own SHA would never
19160
+ // match a commit row.
19161
+ const hash = derefedHash || objectHash;
19043
19162
  return {
19044
19163
  name,
19045
19164
  hash,
@@ -19051,7 +19170,7 @@ function parseTagRefs(output) {
19051
19170
  async function getTagOverview(git) {
19052
19171
  const output = await git.raw([
19053
19172
  'for-each-ref',
19054
- `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19173
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
19055
19174
  '--sort=-creatordate',
19056
19175
  'refs/tags',
19057
19176
  ]);
@@ -19933,6 +20052,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
19933
20052
  output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
19934
20053
  }
19935
20054
 
20055
+ /**
20056
+ * Shared hash-matching helpers for cross-command lookups.
20057
+ *
20058
+ * Git surfaces the same commit with different short-hash lengths
20059
+ * depending on which command produced the row:
20060
+ *
20061
+ * - `for-each-ref --format=%(objectname:short)` (branches, tags,
20062
+ * stashes) honors `core.abbrev`, typically 7 chars.
20063
+ * - `git log --pretty=format:%h` (history rows) honors the same
20064
+ * setting BUT git auto-extends abbreviations to keep them unique
20065
+ * within the walked set — so the same commit can come back as 7
20066
+ * chars from one command and 8 (or more) from another.
20067
+ *
20068
+ * Consequence: any exact-equality lookup that compares a hash from
20069
+ * `for-each-ref` against a hash from `git log` will miss the match
20070
+ * even when both refer to the same commit. This bit the workstation's
20071
+ * cursor-sync effect twice during 0.54.2 — once in the resolver, once
20072
+ * in the `selectCommitByHash` reducer — and shows up wherever a ref
20073
+ * hash is checked against the loaded log window.
20074
+ *
20075
+ * The fix is bidirectional prefix matching: a hash matches another if
20076
+ * one is a prefix of the other. Below a 4-char floor we refuse to
20077
+ * match — three chars would collide with too many real commits.
20078
+ *
20079
+ * This module is the canonical place for that logic. Import it
20080
+ * anywhere you compare a "hash from one git formatter" against a
20081
+ * "hash from a different git formatter."
20082
+ *
20083
+ * Lives in `src/git/` because both `workstation/` and `commands/log/`
20084
+ * depend on it — `commands/log/` must not depend on `workstation/`,
20085
+ * so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
20086
+ */
20087
+ /**
20088
+ * Minimum length below which we refuse to prefix-match. Three chars
20089
+ * is too small to be a meaningful unique prefix for any real-world
20090
+ * git history.
20091
+ */
20092
+ const MIN_PREFIX_LENGTH = 4;
20093
+ /**
20094
+ * True when `a` and `b` refer to the same commit, tolerating
20095
+ * short-hash length differences from different git formatters.
20096
+ *
20097
+ * Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
20098
+ * string equality wins immediately (the common path); otherwise we
20099
+ * test bidirectional `startsWith` and bail when either input is too
20100
+ * short to be a meaningful prefix.
20101
+ */
20102
+ function hashesMatch(a, b) {
20103
+ if (!a || !b)
20104
+ return false;
20105
+ if (a === b)
20106
+ return true;
20107
+ if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
20108
+ return false;
20109
+ return a.startsWith(b) || b.startsWith(a);
20110
+ }
20111
+ /**
20112
+ * True when `hash` matches any entry in `candidates`. Convenience
20113
+ * wrapper for the common "is this ref's hash in any of the row's
20114
+ * hash variants?" check.
20115
+ */
20116
+ function hashesMatchAny(hash, candidates) {
20117
+ if (!hash)
20118
+ return false;
20119
+ return candidates.some((candidate) => hashesMatch(hash, candidate));
20120
+ }
20121
+ /**
20122
+ * True when `hash` is present in the loaded set — exact match first
20123
+ * (the O(1) fast path), then bidirectional `startsWith` over the set
20124
+ * to cover the formatter mismatch.
20125
+ *
20126
+ * The set is small in practice (1k–5k entries) so O(N) iteration on
20127
+ * miss is fine.
20128
+ */
20129
+ function hashLoaded(hash, loaded) {
20130
+ if (loaded.has(hash))
20131
+ return true;
20132
+ if (hash.length < MIN_PREFIX_LENGTH)
20133
+ return false;
20134
+ for (const entry of loaded) {
20135
+ if (entry.startsWith(hash) || hash.startsWith(entry))
20136
+ return true;
20137
+ }
20138
+ return false;
20139
+ }
20140
+
19936
20141
  const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
19937
20142
  /**
19938
20143
  * Parse a single `.gitattributes` body into the LFS-tracked
@@ -20220,9 +20425,16 @@ function applyCommitComposeAction(state, action) {
20220
20425
  field: state.field === 'summary' ? 'body' : 'summary',
20221
20426
  };
20222
20427
  case 'setEditing':
20428
+ // Audit finding #12: defensively clear `streamingPreview` when
20429
+ // editing toggles off AND no draft is in flight. The current
20430
+ // input pipeline never triggers this combination, but the
20431
+ // reducer is the source of truth — if a future code path
20432
+ // toggles editing off mid-stream, the preview shouldn't linger
20433
+ // below an idle compose panel.
20223
20434
  return {
20224
20435
  ...state,
20225
20436
  editing: action.value,
20437
+ streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
20226
20438
  };
20227
20439
  case 'setLoading':
20228
20440
  // Clearing loading also clears any in-flight streaming preview;
@@ -20234,6 +20446,22 @@ function applyCommitComposeAction(state, action) {
20234
20446
  streamingPreview: action.value ? state.streamingPreview : undefined,
20235
20447
  };
20236
20448
  case 'setDraft':
20449
+ // Audit finding #7: if the user has typed content in summary or
20450
+ // body, the AI draft would silently clobber their work with no
20451
+ // undo. Route the result to `pendingAiDraft` instead and surface
20452
+ // a confirmation message; the user accepts with `R` (replace)
20453
+ // or dismisses with Esc. Empty fields = safe to replace as
20454
+ // before, since there's nothing to lose.
20455
+ if (state.summary.trim() || state.body.trim()) {
20456
+ return {
20457
+ ...state,
20458
+ loading: false,
20459
+ streamingPreview: undefined,
20460
+ pendingAiDraft: action.value,
20461
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20462
+ details: undefined,
20463
+ };
20464
+ }
20237
20465
  // No `message` here — the loader → filled fields are the confirmation
20238
20466
  // that the AI generated something. A lingering "AI draft ready for
20239
20467
  // editing" line in the panel reads as stale state. The runtime still
@@ -20248,6 +20476,7 @@ function applyCommitComposeAction(state, action) {
20248
20476
  message: undefined,
20249
20477
  details: undefined,
20250
20478
  streamingPreview: undefined,
20479
+ pendingAiDraft: undefined,
20251
20480
  };
20252
20481
  case 'setResult':
20253
20482
  return {
@@ -20267,6 +20496,46 @@ function applyCommitComposeAction(state, action) {
20267
20496
  ...state,
20268
20497
  streamingPreview: action.value,
20269
20498
  };
20499
+ case 'setPendingAiDraft':
20500
+ // Audit finding #7: route the AI draft here (instead of straight
20501
+ // to summary/body via `setDraft`) when the user has unsaved
20502
+ // typing the draft would clobber. The dispatcher does the
20503
+ // user-content check; this reducer just stashes the draft and
20504
+ // surfaces a message inviting the user to accept or dismiss.
20505
+ return {
20506
+ ...state,
20507
+ loading: false,
20508
+ streamingPreview: undefined,
20509
+ pendingAiDraft: action.value,
20510
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20511
+ details: undefined,
20512
+ };
20513
+ case 'acceptPendingAiDraft':
20514
+ // Swap the pending draft into the editable fields and clear it.
20515
+ // Mirrors `setDraft`'s field positioning (focus on summary,
20516
+ // editing on) so the user lands in the same place whether they
20517
+ // accepted immediately or after deliberation.
20518
+ if (!state.pendingAiDraft)
20519
+ return state;
20520
+ return {
20521
+ ...state,
20522
+ ...splitCommitDraft(state.pendingAiDraft),
20523
+ field: 'summary',
20524
+ editing: true,
20525
+ loading: false,
20526
+ message: undefined,
20527
+ details: undefined,
20528
+ streamingPreview: undefined,
20529
+ pendingAiDraft: undefined,
20530
+ };
20531
+ case 'dismissPendingAiDraft':
20532
+ // User chose to keep their typing; drop the AI draft.
20533
+ return {
20534
+ ...state,
20535
+ pendingAiDraft: undefined,
20536
+ message: undefined,
20537
+ details: undefined,
20538
+ };
20270
20539
  case 'reset':
20271
20540
  // Drop message/details too — the post-commit "Created commit ..."
20272
20541
  // notification is already on the runtime status line (footer); a
@@ -20454,6 +20723,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20454
20723
  // classify below.
20455
20724
  const stream = await chain.stream(variables, signal ? { signal } : undefined);
20456
20725
  let chunkCount = 0;
20726
+ let callbackFailureCount = 0;
20727
+ // Audit finding #13: cap consecutive callback failures so a
20728
+ // genuinely broken render handler can't tie up the LLM call
20729
+ // silently for the user's entire wait. Five strikes (out of an
20730
+ // expected ~50-500 chunks for a normal commit message) is enough
20731
+ // to ride out a transient blip but small enough to bail before
20732
+ // the user finishes waiting on a useless stream.
20733
+ const MAX_CALLBACK_FAILURES = 5;
20457
20734
  for await (const messageChunk of stream) {
20458
20735
  const text = coerceChunkText(messageChunk);
20459
20736
  if (!text)
@@ -20462,12 +20739,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20462
20739
  chunkCount += 1;
20463
20740
  try {
20464
20741
  onChunk({ text, accumulated });
20742
+ // Successful callback resets the consecutive-failure counter —
20743
+ // we only bail on a STREAK of failures, not on isolated ones.
20744
+ callbackFailureCount = 0;
20465
20745
  }
20466
20746
  catch (callbackError) {
20467
20747
  // Deliberately swallow callback errors so a bad render handler
20468
20748
  // can't tank the entire LLM call. Log at verbose so users with
20469
20749
  // verbose mode on can still see what happened.
20470
- logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20750
+ callbackFailureCount += 1;
20751
+ logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20752
+ if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
20753
+ logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
20754
+ 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 });
20755
+ }
20471
20756
  }
20472
20757
  }
20473
20758
  if (!accumulated) {
@@ -20501,15 +20786,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20501
20786
  }
20502
20787
  catch (error) {
20503
20788
  // Cancellation classifier (#881 phase 3). Three signals: an
20504
- // explicitly aborted user signal (post-throw check), the
20505
- // standard DOM `AbortError`, or a Node `AbortSignal` with
20506
- // `signal.aborted === true` while a chain-internal error
20507
- // propagates. Any of these means "user wanted out," not "the
20508
- // call failed." Wrap the raw error so callers can pattern-match
20509
- // on `LangChainCancelledError` and carry the partial accumulated
20510
- // text in case the caller wants to salvage anything.
20789
+ // explicitly aborted user signal (post-throw check) or a thrown
20790
+ // `AbortError` from the standard DOM API. Either means "user
20791
+ // wanted out," not "the call failed." Wrap the raw error so
20792
+ // callers can pattern-match on `LangChainCancelledError` and
20793
+ // carry the partial accumulated text in case the caller wants
20794
+ // to salvage anything.
20795
+ //
20796
+ // Audit finding #8: an earlier implementation also fell back to
20797
+ // `error.message.includes('aborted')` as a third signal. That
20798
+ // substring heuristic is footgun-shaped — legitimate provider
20799
+ // errors ("model not aborted properly", future API copy) would
20800
+ // misclassify as user cancels. Dropped; rely on the structured
20801
+ // signal (`signal.aborted`) and the standard error class
20802
+ // (`name === 'AbortError'`).
20511
20803
  const aborted = signal?.aborted ||
20512
- (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
20804
+ (error instanceof Error && error.name === 'AbortError');
20513
20805
  if (aborted) {
20514
20806
  throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20515
20807
  provider: effectiveProvider,
@@ -20768,6 +21060,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20768
21060
  // schema-validated retry — paying for a second LLM call only
20769
21061
  // on the edge case where the streamed output is unsalvageable.
20770
21062
  const streamingParser = createSchemaParser(schema, llm);
21063
+ // Capture the final accumulated text out-of-band so we can
21064
+ // attempt salvage if the parser throws on completion (audit
21065
+ // finding #1). Updated on every chunk; the last value is
21066
+ // whatever the stream produced before the parser ran. Empty
21067
+ // string when streaming throws before any chunks arrived.
21068
+ let streamedAccumulated = '';
20771
21069
  let salvaged;
20772
21070
  try {
20773
21071
  // `executeChainStreaming` runs the parser on the accumulated
@@ -20781,6 +21079,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20781
21079
  variables: budgetedPrompt.variables,
20782
21080
  parser: streamingParser,
20783
21081
  onChunk: ({ text, accumulated }) => {
21082
+ streamedAccumulated = accumulated;
20784
21083
  onStreamChunk(text, accumulated);
20785
21084
  },
20786
21085
  signal,
@@ -20810,13 +21109,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20810
21109
  cancelled: true,
20811
21110
  };
20812
21111
  }
20813
- // Streamed accumulated text didn't parse cleanly. Try the
20814
- // lossy salvager on whatever we have; if that produces a
20815
- // non-placeholder title, accept it. Otherwise fall through
20816
- // to the non-streaming path which can retry with a fresh
20817
- // LLM call.
20818
- logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20819
- salvaged = undefined;
21112
+ // Audit finding #1: try the lossy salvager on the accumulated
21113
+ // text before paying for a second LLM call. The salvager
21114
+ // strips code fences, attempts strict JSON parse, and falls
21115
+ // back to "first line is title, rest is body." We only accept
21116
+ // its output when it produced a real title — the placeholder
21117
+ // title ("Auto-generated commit") means the salvager
21118
+ // couldn't extract anything meaningful and the non-streaming
21119
+ // retry is the better choice.
21120
+ if (streamedAccumulated) {
21121
+ const candidate = salvageCommitMessageFromText(streamedAccumulated);
21122
+ if (candidate.title && candidate.title !== 'Auto-generated commit') {
21123
+ salvaged = candidate;
21124
+ logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
21125
+ }
21126
+ }
21127
+ if (!salvaged) {
21128
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
21129
+ }
20820
21130
  }
20821
21131
  // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20822
21132
  // see that across the catch. Re-init through the salvage path
@@ -20825,10 +21135,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20825
21135
  commitMsg = salvaged;
20826
21136
  }
20827
21137
  else if (!(commitMsg)) {
20828
- // Streaming threw; do the standard non-streaming flow to
20829
- // recover. This is the trade-off documented in the issue —
20830
- // streaming gives us a preview but the validated result still
20831
- // comes from the schema-aware retry path when streaming fails.
21138
+ // Streaming threw AND the salvager couldn't recover anything
21139
+ // useful; fall back to the standard non-streaming flow.
21140
+ // Documented trade-off from the issue: streaming gives us a
21141
+ // preview but the validated result still comes from the
21142
+ // schema-aware retry path when both streaming AND salvage
21143
+ // fail.
20832
21144
  commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20833
21145
  logger,
20834
21146
  tokenizer,
@@ -23518,7 +23830,16 @@ function createLogInkState(rows, options = {}) {
23518
23830
  worktreeDiffOffset: 0,
23519
23831
  filter: '',
23520
23832
  filterMode: false,
23521
- fullGraph: false,
23833
+ // Default to the full multi-ref graph (`git log --all`) so users
23834
+ // see how branches, tags, and stashes weave through the history
23835
+ // out of the box. Pre-0.54.x this defaulted to false (current
23836
+ // branch only); user feedback consistently asked for the
23837
+ // GitKraken-style "see everything" view as the starting state.
23838
+ // The `\` toggle still flips back to compact / current-branch
23839
+ // mode for users who want the cleaner single-line graph. Tests
23840
+ // override via `options.fullGraph` when they need the compact
23841
+ // case explicitly.
23842
+ fullGraph: options.fullGraph ?? true,
23522
23843
  showHelp: false,
23523
23844
  helpScrollOffset: 0,
23524
23845
  showCommandPalette: false,
@@ -23627,8 +23948,17 @@ function applyLogInkAction(state, action) {
23627
23948
  // branch's tip without the user manually scrolling. No-op when
23628
23949
  // the hash isn't in the loaded list (the runtime surfaces a
23629
23950
  // status hint in that case).
23951
+ //
23952
+ // Uses the shared `hashesMatchAny` helper to cover the
23953
+ // short-hash auto-extension mismatch between
23954
+ // `for-each-ref --format=%(objectname:short)` (cursored ref)
23955
+ // and `git log --pretty=format:%h` (history row). Without that
23956
+ // tolerance the resolver could decide "jump" but this reducer
23957
+ // would silently no-op — the status updates but the cursor
23958
+ // doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
23959
+ // testing. See `src/git/hashes.ts` for the matching rules.
23630
23960
  const target = action.hash;
23631
- const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
23961
+ const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
23632
23962
  if (index < 0) {
23633
23963
  return state;
23634
23964
  }
@@ -24271,10 +24601,14 @@ function applyLogInkAction(state, action) {
24271
24601
  // Cache the result so re-entry (or `c` to PR) reuses it instead of
24272
24602
  // re-running the LLM. Keyed by branch so a checkout naturally
24273
24603
  // produces a fresh generation.
24604
+ // Audit finding #9: `generatedAt` arrives on the action payload
24605
+ // instead of being read from `Date.now()` here, so the reducer
24606
+ // stays pure. Dispatchers (currently `runChangelogView` in
24607
+ // app.ts) call `Date.now()` at dispatch time.
24274
24608
  const cached = {
24275
24609
  text: action.text,
24276
24610
  baseLabel: action.baseLabel,
24277
- generatedAt: Date.now(),
24611
+ generatedAt: action.generatedAt,
24278
24612
  };
24279
24613
  return {
24280
24614
  ...state,
@@ -24328,7 +24662,8 @@ function applyLogInkAction(state, action) {
24328
24662
  // Updated-at timestamp reflects the edit. Not the original
24329
24663
  // generation time — `r` (regenerate) is the explicit knob
24330
24664
  // for "I want fresh LLM output, not my edits".
24331
- generatedAt: Date.now(),
24665
+ // Audit finding #9: timestamp arrives on the action.
24666
+ generatedAt: action.generatedAt,
24332
24667
  },
24333
24668
  },
24334
24669
  pendingKey: undefined,
@@ -24364,7 +24699,9 @@ function applyLogInkAction(state, action) {
24364
24699
  }
24365
24700
  return {
24366
24701
  ...state,
24367
- recentCommitHashes: { hashes: action.hashes, markedAt: Date.now() },
24702
+ // Audit finding #9: timestamp arrives on the action payload
24703
+ // instead of being read from `Date.now()` here.
24704
+ recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
24368
24705
  pendingKey: undefined,
24369
24706
  };
24370
24707
  case 'clearRecentCommits':
@@ -24558,7 +24895,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24558
24895
  const commit = state.filteredCommits[state.selectedIndex];
24559
24896
  const requireCommit = (fn) => {
24560
24897
  if (!commit) {
24561
- return [action({ type: 'setStatus', value: 'No commit selected' })];
24898
+ return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
24562
24899
  }
24563
24900
  return fn(commit.hash, state.selectedIndex);
24564
24901
  };
@@ -24597,6 +24934,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
24597
24934
  return [action({
24598
24935
  type: 'setStatus',
24599
24936
  value: `Action ${inspectorAction.key} not yet wired`,
24937
+ kind: 'warning',
24600
24938
  })];
24601
24939
  }
24602
24940
  }
@@ -24811,6 +25149,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24811
25149
  return [action({
24812
25150
  type: 'setStatus',
24813
25151
  value: 'open the diff view and press [ or ] to jump hunks',
25152
+ kind: 'warning',
24814
25153
  })];
24815
25154
  case 'focusNext':
24816
25155
  return [action({ type: 'focusNext' })];
@@ -24859,6 +25198,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24859
25198
  return [action({
24860
25199
  type: 'setStatus',
24861
25200
  value: 'open branches / tags / history and press m on the cursored ref',
25201
+ kind: 'warning',
24862
25202
  })];
24863
25203
  case 'navigateBack':
24864
25204
  // Mirror the Esc / `<` semantics (#931): drain the frame's view
@@ -24934,6 +25274,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
24934
25274
  return [action({
24935
25275
  type: 'setStatus',
24936
25276
  value: 'Sort cycle is available in the branches and tags views',
25277
+ kind: 'warning',
24937
25278
  })];
24938
25279
  case 'yankClipboard':
24939
25280
  // The runtime resolves the value/label against the live filtered
@@ -24990,7 +25331,7 @@ function submitInputPrompt(state) {
24990
25331
  return [];
24991
25332
  const value = state.inputPrompt.value.trim();
24992
25333
  if (!value) {
24993
- return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
25334
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
24994
25335
  }
24995
25336
  if (state.inputPrompt.kind === 'reset-mode') {
24996
25337
  const mode = value.toLowerCase();
@@ -24998,6 +25339,7 @@ function submitInputPrompt(state) {
24998
25339
  return [action({
24999
25340
  type: 'setStatus',
25000
25341
  value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
25342
+ kind: 'warning',
25001
25343
  })];
25002
25344
  }
25003
25345
  return [
@@ -25011,6 +25353,7 @@ function submitInputPrompt(state) {
25011
25353
  return [action({
25012
25354
  type: 'setStatus',
25013
25355
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25356
+ kind: 'warning',
25014
25357
  })];
25015
25358
  }
25016
25359
  return [
@@ -25074,6 +25417,7 @@ function submitInputPrompt(state) {
25074
25417
  return [action({
25075
25418
  type: 'setStatus',
25076
25419
  value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
25420
+ kind: 'warning',
25077
25421
  })];
25078
25422
  }
25079
25423
  return [
@@ -25164,16 +25508,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25164
25508
  return [];
25165
25509
  }
25166
25510
  // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25167
- // surface is mid-stream (loading === true), Esc aborts the LLM call
25168
- // and the runtime handler cleans up (clear loading, clear preview,
25169
- // status line shows "AI draft cancelled."). Sits above the editing
25170
- // / view handlers so the cancel keystroke can't fall through to
25171
- // "leave compose" or anything else.
25511
+ // state has a draft in flight (loading === true), Esc aborts the
25512
+ // LLM call and the runtime handler cleans up (clear loading, clear
25513
+ // preview, status line shows "AI draft cancelled.").
25172
25514
  //
25173
- // Loading and editing are mutually exclusive in practice (the user
25174
- // can't type while the AI is generating), but the order here makes
25175
- // the precedence explicit if that ever changes.
25176
- if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
25515
+ // Audit finding #5: the `activeView === 'compose'` gate from the
25516
+ // original phase 3 implementation made the cancel keystroke
25517
+ // unreachable after the user chord-navigated away from compose
25518
+ // mid-stream (Esc would fall through to popView etc., consuming
25519
+ // the navigation intent while the LLM call silently ran to
25520
+ // completion). Cancel should work wherever the user is — they
25521
+ // can always navigate back to compose afterwards.
25522
+ //
25523
+ // Sits above the editing / view handlers so the cancel keystroke
25524
+ // can't fall through to "leave compose" or anything else. Loading
25525
+ // and editing are mutually exclusive in practice (the user can't
25526
+ // type while the AI is generating), but the order here makes the
25527
+ // precedence explicit if that ever changes.
25528
+ if (state.commitCompose.loading && key.escape) {
25177
25529
  return [{ type: 'cancelAiCommitDraft' }];
25178
25530
  }
25179
25531
  // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
@@ -25193,6 +25545,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25193
25545
  if (state.pendingPullRequestBodyDraft && key.escape) {
25194
25546
  return [{ type: 'cancelPullRequestBodyDraft' }];
25195
25547
  }
25548
+ // Pending AI draft confirmation (audit finding #7). When the AI
25549
+ // draft completes against a non-empty compose surface, it lands in
25550
+ // `pendingAiDraft` instead of overwriting the user's typing. `R`
25551
+ // accepts the swap (user's typing is lost, AI draft becomes the
25552
+ // new content). `Esc` dismisses the AI draft (typing is preserved,
25553
+ // AI draft is lost — the user paid for the tokens but explicitly
25554
+ // chose not to use them).
25555
+ //
25556
+ // Gated on `activeView === 'compose'` because the pending draft is
25557
+ // only meaningful on the compose surface (where the message line
25558
+ // surfaces the prompt). A user who chord-navigated away while the
25559
+ // draft was pending should see the original `R` / Esc semantics of
25560
+ // wherever they are now.
25561
+ if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
25562
+ if (inputValue === 'R' && !key.ctrl && !key.meta) {
25563
+ return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
25564
+ }
25565
+ if (key.escape) {
25566
+ return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
25567
+ }
25568
+ }
25196
25569
  if (state.commitCompose.editing) {
25197
25570
  if (key.escape) {
25198
25571
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -25642,7 +26015,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25642
26015
  }
25643
26016
  return [
25644
26017
  action({ type: 'setPendingKey', value: undefined }),
25645
- action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
26018
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
25646
26019
  ];
25647
26020
  }
25648
26021
  // `gT` chord: create a lightweight tag at the cursored commit on the
@@ -25666,7 +26039,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25666
26039
  }
25667
26040
  return [
25668
26041
  action({ type: 'setPendingKey', value: undefined }),
25669
- action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
26042
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
25670
26043
  ];
25671
26044
  }
25672
26045
  // #784 — bisect view action keys. Scoped to `state.activeView ===
@@ -25781,6 +26154,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25781
26154
  value: next === 'split'
25782
26155
  ? 'Switched to side-by-side diff'
25783
26156
  : 'Switched to unified diff',
26157
+ kind: 'success',
25784
26158
  }),
25785
26159
  ];
25786
26160
  }
@@ -26231,10 +26605,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26231
26605
  if (key.return && state.compareBase && isCompareFlowTarget(state)) {
26232
26606
  const head = getCursoredCompareRef(state, context);
26233
26607
  if (!head) {
26234
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26608
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26235
26609
  }
26236
26610
  if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
26237
- return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
26611
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
26238
26612
  }
26239
26613
  return [
26240
26614
  action({
@@ -26437,7 +26811,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26437
26811
  action({ type: 'setFocus', value: 'commits' }),
26438
26812
  ];
26439
26813
  }
26440
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
26814
+ return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
26441
26815
  }
26442
26816
  // Fall through — per-entity Enter handler below claims the keystroke.
26443
26817
  }
@@ -26548,7 +26922,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26548
26922
  if (inputValue === 'm' && isCompareFlowTarget(state)) {
26549
26923
  const ref = getCursoredCompareRef(state, context);
26550
26924
  if (!ref) {
26551
- return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
26925
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
26552
26926
  }
26553
26927
  if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
26554
26928
  return [
@@ -26779,7 +27153,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26779
27153
  // Always intercept `C` on the conflicts view to prevent fallthrough to
26780
27154
  // the global `C` (Create PR) binding when conflicts remain.
26781
27155
  if (inputValue === 'C' && state.activeView === 'conflicts') {
26782
- return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
27156
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
26783
27157
  }
26784
27158
  // Global `C` — create a pull request from the current branch. The
26785
27159
  // runtime callback handles pre-flight (current branch resolution,
@@ -26795,6 +27169,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26795
27169
  return [action({
26796
27170
  type: 'setStatus',
26797
27171
  value: 'Finish or cancel the commit draft before creating a PR.',
27172
+ kind: 'warning',
26798
27173
  })];
26799
27174
  }
26800
27175
  if (inputValue === 'C' && state.activeView !== 'conflicts') {
@@ -26854,7 +27229,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26854
27229
  return events;
26855
27230
  }
26856
27231
  if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
26857
- return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
27232
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
26858
27233
  }
26859
27234
  }
26860
27235
  // `c` on the history view cherry-picks the full selected commit on
@@ -27739,7 +28114,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
27739
28114
  rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
27740
28115
  tight: { min: 22, max: 28, fraction: 0.24 },
27741
28116
  normal: { min: 22, max: 30, fraction: 0.22 },
27742
- wide: { min: 28, max: 48, fraction: 0.24 },
28117
+ wide: { min: 28, max: 32, fraction: 0.20 },
27743
28118
  };
27744
28119
  function calcSidebarAtRestWidth(columns, density) {
27745
28120
  const config = SIDEBAR_AT_REST_BY_TIER[density];
@@ -29692,18 +30067,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
29692
30067
  ];
29693
30068
  }
29694
30069
 
30070
+ function resolveCursorSyncDecision(input) {
30071
+ if (!input.target) {
30072
+ return { type: 'noop', reason: 'no-target' };
30073
+ }
30074
+ if (input.target.hash === input.lastSyncedHash) {
30075
+ return { type: 'noop', reason: 'duplicate-of-last' };
30076
+ }
30077
+ if (isHashLoaded(input.target.hash, input.loadedHashes)) {
30078
+ return {
30079
+ type: 'jump',
30080
+ hash: input.target.hash,
30081
+ label: input.target.label,
30082
+ };
30083
+ }
30084
+ if (input.attemptedContextHashes.has(input.target.hash)) {
30085
+ return { type: 'unreachable', target: input.target };
30086
+ }
30087
+ return { type: 'load-context', target: input.target };
30088
+ }
30089
+ /**
30090
+ * Re-export of the shared `hashLoaded` helper under the resolver's
30091
+ * historical name. Kept exported so existing tests (and any external
30092
+ * importers) keep working unchanged — see `src/git/hashes.ts` for the
30093
+ * canonical implementation and the rationale behind bidirectional
30094
+ * prefix matching.
30095
+ */
30096
+ function isHashLoaded(hash, loadedHashes) {
30097
+ return hashLoaded(hash, loadedHashes);
30098
+ }
30099
+ /**
30100
+ * Build the membership set the resolver expects. Includes BOTH the
30101
+ * full hash and the short hash for every commit so the caller can
30102
+ * match either form (refs sometimes carry only the short hash and
30103
+ * `state.filteredCommits` items always have both).
30104
+ *
30105
+ * Exported so the cursor-sync effect can build the set once per
30106
+ * re-render and pass it down without leaking the implementation
30107
+ * detail. Tests use it to construct realistic inputs without
30108
+ * hand-rolling the dual-hash logic.
30109
+ */
30110
+ function buildLoadedHashSet(commits) {
30111
+ const set = new Set();
30112
+ for (const commit of commits) {
30113
+ if (commit.hash)
30114
+ set.add(commit.hash);
30115
+ if (commit.shortHash)
30116
+ set.add(commit.shortHash);
30117
+ }
30118
+ return set;
30119
+ }
30120
+
29695
30121
  /**
29696
- * Status-bar / footer renderer. Two-column layout:
29697
- * - Left: contextual hints for the active view (built by inkKeymap's
29698
- * `getLogInkFooterHints`), with the optional status message / idle
29699
- * tip appended.
29700
- * - Right: global key hints (`?` help, `:` palette, `q` quit, …).
30122
+ * Status-bar / footer renderer. Two-row layout, using the full
30123
+ * `height: 2` the footer already reserves:
30124
+ *
30125
+ * Row 1 — keyboard hint band:
30126
+ * ┌──── contextual hints ────┐ ┌──── globals ────┐
30127
+ * ↑/↓ branches ←/→ tab … ? help · : cmds · q
30128
+ *
30129
+ * Row 2 — status / feedback band:
30130
+ * ⠋ main has no upstream — nothing to fetch.
30131
+ *
30132
+ * Row 2 is empty when there's no status message, idle tip, or error.
30133
+ * This is a behaviour change from the pre-0.54.2 single-row layout
30134
+ * where the status message sat awkwardly between the contextual and
30135
+ * global hints, getting visually crushed.
29701
30136
  *
29702
- * Idle tips only fill the slot when no real status message is set so the
30137
+ * The separation matters because:
30138
+ * - status text and key hints serve different cognitive purposes
30139
+ * (read vs. scan) and competing for the same row makes both
30140
+ * harder to use,
30141
+ * - long status messages (especially errors / multi-clause loading
30142
+ * copy) no longer push global hints off screen or wrap into the
30143
+ * hint cluster,
30144
+ * - errors now keep the global hints visible — the user often
30145
+ * needs `?` / `:` / `q` to *recover* from the error.
30146
+ *
30147
+ * Idle tips fill row 2 only when no real status message is set so the
29703
30148
  * tip cycle never overwrites genuine workflow feedback.
29704
30149
  *
29705
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
29706
- * of #890. No behavior change.
30150
+ * Row 2 styling is kind-aware. Each statusKind gets its own theme
30151
+ * color and glyph prefix so the message is identifiable at a glance
30152
+ * — even with NO_COLOR set, the glyph alone communicates kind:
30153
+ *
30154
+ * loading → spinner + accent + bold
30155
+ * error → ✗ / ! + danger + bold
30156
+ * warning → ⚠ / ! + warning + bold
30157
+ * success → ✓ / + + success + bold
30158
+ * info → ℹ / i + info + bold
30159
+ * idle tip → no glyph + dim muted (passive)
30160
+ *
30161
+ * Pre-redesign success and loading both used `accent` (cyan), so the
30162
+ * user couldn't tell "done" from "in progress" by color alone. Each
30163
+ * kind now uses its dedicated theme color and ships an ASCII glyph
30164
+ * fallback for `theme.ascii` mode (TERM=dumb / vt100).
30165
+ *
30166
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30167
+ * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
30168
+ * colors + glyphs added in the same pass.
29707
30169
  */
29708
30170
  function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
29709
30171
  const { Box, Text } = components;
@@ -29735,50 +30197,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
29735
30197
  });
29736
30198
  // Real status messages always win; idle tips only fill the slot when it
29737
30199
  // would otherwise be empty.
29738
- const isLoading = Boolean(state.statusLoading && state.statusMessage);
29739
- const trailing = state.statusMessage || idleTip || '';
29740
- // Loading status gets a spinner prefix in front of the message —
29741
- // motion makes transient LLM calls (create-PR body, PR fetches,
29742
- // etc.) feel less frozen even when they're sub-second.
29743
- const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
29744
- const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
29745
- const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
30200
+ const hasStatusMessage = Boolean(state.statusMessage);
30201
+ const isLoading = Boolean(state.statusLoading && hasStatusMessage);
29746
30202
  const isError = state.statusKind === 'error';
30203
+ const isWarning = state.statusKind === 'warning';
29747
30204
  const isSuccess = state.statusKind === 'success';
29748
- const contextualText = isError
29749
- // Errors get the full footer width and a `✗` prefix so they read
29750
- // as alarming. We drop the contextual hints when an error is
29751
- // active they'd compete for attention with the message and
29752
- // long validator outputs (#907 polish: split-plan validator
29753
- // errors are often 100+ chars and got truncated against the hints).
29754
- ? `✗ ${state.statusMessage || ''}`
29755
- : `${hints.contextual.join(' ')}${status}`;
30205
+ // 'info' is the implicit kind when statusKind is undefined but
30206
+ // statusMessage is set it's a deliberate status update, not an
30207
+ // idle tip, so it gets info treatment rather than the dim fallback.
30208
+ const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
30209
+ const rawTrailing = state.statusMessage || idleTip || '';
30210
+ // Glyphs per kind so the message is identifiable even before reading
30211
+ // the color improves scan-ability and degrades gracefully when the
30212
+ // terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
30213
+ // = dumb / vt100) where unicode glyphs render as garbage.
30214
+ // loading → spinner (animated)
30215
+ // error → ✗ / !
30216
+ // warning → ⚠ / !
30217
+ // success → ✓ / +
30218
+ // info → ℹ / i
30219
+ // idle tip → no glyph (passive)
30220
+ const glyph = (() => {
30221
+ if (isLoading)
30222
+ return pickSpinnerFrame(spinnerFrame);
30223
+ if (isError)
30224
+ return theme.ascii ? '!' : '✗';
30225
+ if (isWarning)
30226
+ return theme.ascii ? '!' : '⚠';
30227
+ if (isSuccess)
30228
+ return theme.ascii ? '+' : '✓';
30229
+ if (isInfo)
30230
+ return theme.ascii ? 'i' : 'ℹ';
30231
+ return '';
30232
+ })();
30233
+ const statusBody = rawTrailing
30234
+ ? glyph
30235
+ ? `${glyph} ${rawTrailing}`
30236
+ : rawTrailing
30237
+ : '';
30238
+ // Row 2 color picks. Each kind gets its own theme color so success
30239
+ // and loading are visually distinct (was conflated under `accent`
30240
+ // pre-redesign — users couldn't tell "done" from "in progress").
30241
+ // loading → accent (cyan / preset blue)
30242
+ // error → danger (red / preset red)
30243
+ // warning → warning (yellow)
30244
+ // success → success (green)
30245
+ // info → info (blue / preset accent in light themes)
30246
+ // idle → undefined + dim (passive, blends with chrome)
30247
+ const statusColor = isError
30248
+ ? theme.colors.danger
30249
+ : isWarning
30250
+ ? theme.colors.warning
30251
+ : isSuccess
30252
+ ? theme.colors.success
30253
+ : isLoading
30254
+ ? theme.colors.accent
30255
+ : isInfo
30256
+ ? theme.colors.info
30257
+ : undefined;
30258
+ const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
30259
+ const statusDim = !statusBold;
30260
+ const hintsText = hints.contextual.join(' ');
29756
30261
  const globalText = hints.global.join(' · ');
29757
- // Error rendering: hide the global hints on the right so the
29758
- // message can wrap into that space. Success rendering: accent
29759
- // color on the message, hints stay visible. Default: existing
29760
- // muted styling.
29761
- const contextualColor = isError
29762
- ? 'red'
29763
- : isSuccess
29764
- ? theme.colors.accent
29765
- : theme.colors.muted;
29766
- return h(Box, {
29767
- flexDirection: 'row',
29768
- height: 2,
29769
- justifyContent: 'space-between',
29770
- paddingX: 1,
29771
- }, h(Text, {
29772
- color: contextualColor,
29773
- dimColor: !isError && !isSuccess,
29774
- bold: isError,
29775
- }, contextualText),
29776
- // Globals are dropped entirely when an error is on screen — that
29777
- // space is what the long message needs to render. They come back
29778
- // the moment the status flips to info / success / cleared.
29779
- isError
29780
- ? h(Text, undefined, '')
29781
- : h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
30262
+ return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
30263
+ // Row 1: contextual global hints. justifyContent pushes them
30264
+ // to opposite edges so the eye can scan each cluster as one
30265
+ // block instead of hunting through a single concatenated line.
30266
+ h(Box, { flexDirection: 'row', justifyContent: 'space-between' }, h(Text, { color: theme.colors.muted, dimColor: true }, hintsText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText)),
30267
+ // Row 2: status / loading / idle tip / error. Empty Text keeps
30268
+ // the row reserved when nothing's set so the surrounding layout
30269
+ // doesn't shift as status flips on/off.
30270
+ h(Text, {
30271
+ color: statusColor,
30272
+ dimColor: statusDim,
30273
+ bold: statusBold,
30274
+ }, statusBody));
30275
+ }
30276
+
30277
+ const COMBINING_MARK_RANGES = [
30278
+ [0x0300, 0x036f],
30279
+ [0x1ab0, 0x1aff],
30280
+ [0x1dc0, 0x1dff],
30281
+ [0x20d0, 0x20ff],
30282
+ [0xfe20, 0xfe2f],
30283
+ ];
30284
+ const WIDE_CHARACTER_RANGES = [
30285
+ [0x1100, 0x115f],
30286
+ [0x2329, 0x232a],
30287
+ [0x2e80, 0xa4cf],
30288
+ [0xac00, 0xd7a3],
30289
+ [0xf900, 0xfaff],
30290
+ [0xfe10, 0xfe19],
30291
+ [0xfe30, 0xfe6f],
30292
+ [0xff00, 0xff60],
30293
+ [0xffe0, 0xffe6],
30294
+ [0x2600, 0x27bf],
30295
+ [0x1f000, 0x1f9ff],
30296
+ [0x20000, 0x3fffd],
30297
+ ];
30298
+ function isInRange(codePoint, ranges) {
30299
+ return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30300
+ }
30301
+ function characterWidth(character) {
30302
+ const codePoint = character.codePointAt(0) || 0;
30303
+ if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30304
+ return 0;
30305
+ }
30306
+ if (codePoint === 0x200d ||
30307
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30308
+ isInRange(codePoint, COMBINING_MARK_RANGES)) {
30309
+ return 0;
30310
+ }
30311
+ return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30312
+ }
30313
+ function cellWidth(value) {
30314
+ return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30315
+ }
30316
+ /**
30317
+ * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30318
+ * on whitespace where possible; falls back to mid-word splits when a single
30319
+ * word is wider than the budget. Preserves blank input as a single empty
30320
+ * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30321
+ */
30322
+ function wrapCells(value, width) {
30323
+ if (width < 1) {
30324
+ return [value];
30325
+ }
30326
+ if (cellWidth(value) <= width) {
30327
+ return [value];
30328
+ }
30329
+ const lines = [];
30330
+ let current = '';
30331
+ let currentWidth = 0;
30332
+ const flush = () => {
30333
+ if (current.length > 0) {
30334
+ lines.push(current);
30335
+ current = '';
30336
+ currentWidth = 0;
30337
+ }
30338
+ };
30339
+ // Tokenize into runs of whitespace + non-whitespace so we can keep word
30340
+ // boundaries when possible.
30341
+ const tokens = value.match(/\s+|\S+/g) || [];
30342
+ for (const token of tokens) {
30343
+ const tokenWidth = cellWidth(token);
30344
+ if (currentWidth + tokenWidth <= width) {
30345
+ current += token;
30346
+ currentWidth += tokenWidth;
30347
+ continue;
30348
+ }
30349
+ if (/^\s+$/.test(token)) {
30350
+ // Drop boundary whitespace at line breaks.
30351
+ flush();
30352
+ continue;
30353
+ }
30354
+ flush();
30355
+ if (tokenWidth <= width) {
30356
+ current = token;
30357
+ currentWidth = tokenWidth;
30358
+ continue;
30359
+ }
30360
+ // Word longer than budget — hard-split into chunks.
30361
+ let remaining = token;
30362
+ while (cellWidth(remaining) > width) {
30363
+ let chunk = '';
30364
+ let chunkWidth = 0;
30365
+ for (const character of Array.from(remaining)) {
30366
+ const charW = characterWidth(character);
30367
+ if (chunkWidth + charW > width)
30368
+ break;
30369
+ chunk += character;
30370
+ chunkWidth += charW;
30371
+ }
30372
+ lines.push(chunk);
30373
+ remaining = remaining.slice(chunk.length);
30374
+ }
30375
+ if (remaining.length > 0) {
30376
+ current = remaining;
30377
+ currentWidth = cellWidth(remaining);
30378
+ }
30379
+ }
30380
+ flush();
30381
+ return lines.length > 0 ? lines : [value];
30382
+ }
30383
+ function truncateCells(value, width) {
30384
+ if (width < 1) {
30385
+ return '';
30386
+ }
30387
+ if (cellWidth(value) <= width) {
30388
+ return value;
30389
+ }
30390
+ const suffix = width > 3 ? '...' : '';
30391
+ const available = width - cellWidth(suffix);
30392
+ let used = 0;
30393
+ let output = '';
30394
+ for (const character of Array.from(value)) {
30395
+ const nextWidth = characterWidth(character);
30396
+ if (used + nextWidth > available) {
30397
+ break;
30398
+ }
30399
+ output += character;
30400
+ used += nextWidth;
30401
+ }
30402
+ return `${output}${suffix}`;
30403
+ }
30404
+ /**
30405
+ * Truncate a file path so the filename (last segment) is preserved,
30406
+ * eliding middle directory segments with `…/` instead of dropping
30407
+ * end-of-string characters.
30408
+ *
30409
+ * `truncateCells` is the wrong tool for paths because it preserves the
30410
+ * START of the string and drops the END — losing the filename, which
30411
+ * is the most useful part. Example with `truncateCells`:
30412
+ *
30413
+ * "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
30414
+ *
30415
+ * `truncatePathCells` preserves the filename and elides middle:
30416
+ *
30417
+ * "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
30418
+ *
30419
+ * The algorithm tries successively-smaller prefixes (keeping the start
30420
+ * of the path, the filename, and replacing the dropped middle segments
30421
+ * with `…`) and returns the largest variant that fits. When even
30422
+ * `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
30423
+ * the abbreviated form — better to show end-of-name than start-of-path.
30424
+ *
30425
+ * For inputs without `/` separators, behaves identically to
30426
+ * `truncateCells`. Empty / width-0 cases match `truncateCells` too.
30427
+ *
30428
+ * @example
30429
+ * truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
30430
+ * truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
30431
+ * truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
30432
+ * truncatePathCells('plainname.ts', 8) // 'plain...'
30433
+ */
30434
+ function truncatePathCells(value, width) {
30435
+ if (width < 1)
30436
+ return '';
30437
+ if (cellWidth(value) <= width)
30438
+ return value;
30439
+ // No path structure to exploit — fall through to plain truncation.
30440
+ if (!value.includes('/'))
30441
+ return truncateCells(value, width);
30442
+ const segments = value.split('/');
30443
+ const filename = segments[segments.length - 1] ?? '';
30444
+ const prefix = segments.slice(0, -1);
30445
+ // Path is just '/filename' or has only the filename — no middle to
30446
+ // elide. Defer to plain truncation.
30447
+ if (prefix.length === 0)
30448
+ return truncateCells(value, width);
30449
+ // Walk from "keep all prefix segments except the deepest" down to
30450
+ // "keep no prefix segments." First variant that fits wins.
30451
+ for (let keep = prefix.length - 1; keep >= 0; keep--) {
30452
+ const candidate = keep === 0
30453
+ ? `…/${filename}`
30454
+ : `${prefix.slice(0, keep).join('/')}/…/${filename}`;
30455
+ if (cellWidth(candidate) <= width)
30456
+ return candidate;
30457
+ }
30458
+ // Even `…/<filename>` doesn't fit. Use plain truncation on that
30459
+ // form — preserves the leading `…/` so the user knows a path was
30460
+ // elided, then ellipsis-truncates the filename.
30461
+ return truncateCells(`…/${filename}`, width);
29782
30462
  }
29783
30463
 
29784
30464
  /**
@@ -30005,218 +30685,318 @@ function sidebarTabCount(tab, context) {
30005
30685
  }
30006
30686
  }
30007
30687
 
30008
- const COMBINING_MARK_RANGES = [
30009
- [0x0300, 0x036f],
30010
- [0x1ab0, 0x1aff],
30011
- [0x1dc0, 0x1dff],
30012
- [0x20d0, 0x20ff],
30013
- [0xfe20, 0xfe2f],
30014
- ];
30015
- const WIDE_CHARACTER_RANGES = [
30016
- [0x1100, 0x115f],
30017
- [0x2329, 0x232a],
30018
- [0x2e80, 0xa4cf],
30019
- [0xac00, 0xd7a3],
30020
- [0xf900, 0xfaff],
30021
- [0xfe10, 0xfe19],
30022
- [0xfe30, 0xfe6f],
30023
- [0xff00, 0xff60],
30024
- [0xffe0, 0xffe6],
30025
- [0x2600, 0x27bf],
30026
- [0x1f000, 0x1f9ff],
30027
- [0x20000, 0x3fffd],
30028
- ];
30029
- function isInRange(codePoint, ranges) {
30030
- return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
30031
- }
30032
- function characterWidth(character) {
30033
- const codePoint = character.codePointAt(0) || 0;
30034
- if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
30035
- return 0;
30036
- }
30037
- if (codePoint === 0x200d ||
30038
- (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
30039
- isInRange(codePoint, COMBINING_MARK_RANGES)) {
30040
- return 0;
30041
- }
30042
- return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
30043
- }
30044
- function cellWidth(value) {
30045
- return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
30046
- }
30047
30688
  /**
30048
- * Word-wrap `value` into lines that each fit within `width` cells. Breaks
30049
- * on whitespace where possible; falls back to mid-word splits when a single
30050
- * word is wider than the budget. Preserves blank input as a single empty
30051
- * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
30689
+ * Header chip builder. Turns the workstation's title-bar state into an
30690
+ * ordered list of small visually-distinct chips:
30691
+ *
30692
+ * coco · gfargo/coco · main · ✓ clean · ⊘ no PR · [NORMAL]
30693
+ *
30694
+ * Pre-refactor the title bar concatenated every segment into a single
30695
+ * Text span, which made the eye read the whole thing as one run of
30696
+ * words (the same problem the footer had). Splitting into chips with a
30697
+ * fixed separator lets each segment carry its own color and lets the
30698
+ * user scan the bar in chunks — "what app, what repo, what branch,
30699
+ * how clean, what PR state, what mode" — instead of parsing left-to-
30700
+ * right.
30701
+ *
30702
+ * Why a separate module: the header runtime renders chips and handles
30703
+ * truncation; chip construction is pure transformation of state +
30704
+ * context + theme. Splitting them keeps the chips testable in
30705
+ * isolation and keeps the runtime small.
30706
+ *
30707
+ * Truncation strategy lives in the consumer, not here — when the total
30708
+ * width exceeds the column budget, the header falls back to the
30709
+ * pre-redesign single-fragment truncated string so the ellipsis can't
30710
+ * land mid-glyph. We always return the FULL chip list; the consumer
30711
+ * decides whether to drop chips, fall back, or render all of them.
30052
30712
  */
30053
- function wrapCells(value, width) {
30054
- if (width < 1) {
30055
- return [value];
30713
+ /**
30714
+ * Default separator inserted between chips by the consumer. Exported as
30715
+ * a constant so tests and width math agree on what they're measuring.
30716
+ * The trailing/leading spaces are part of the separator — `·` alone
30717
+ * would butt against adjacent chip labels.
30718
+ */
30719
+ const HEADER_CHIP_SEPARATOR = ' · ';
30720
+ /**
30721
+ * Build the ordered chip list for the header. Chips not relevant to the
30722
+ * current state (no PR loaded, no breadcrumb, no search input, …) are
30723
+ * omitted entirely rather than rendered as empty placeholders, so the
30724
+ * consumer can just `chips.map(render)` without checking for empties.
30725
+ */
30726
+ function buildHeaderChips(input) {
30727
+ const { theme } = input;
30728
+ const chips = [];
30729
+ // App label — the constant identity. Accent + bold so it anchors the
30730
+ // left edge of the bar.
30731
+ chips.push({
30732
+ id: 'app',
30733
+ label: input.appLabel,
30734
+ color: theme.colors.accent,
30735
+ dim: false,
30736
+ bold: true,
30737
+ });
30738
+ // Repo. Default color — it's contextual but not the headline.
30739
+ chips.push({
30740
+ id: 'repo',
30741
+ label: input.repo,
30742
+ color: undefined,
30743
+ dim: false,
30744
+ bold: false,
30745
+ });
30746
+ // Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
30747
+ // is identifiable even when the branch name is generic ("main" /
30748
+ // "master").
30749
+ const branchGlyph = theme.ascii ? 'git:' : '⎇';
30750
+ chips.push({
30751
+ id: 'branch',
30752
+ label: `${branchGlyph} ${input.branch}`,
30753
+ color: theme.colors.accent,
30754
+ dim: false,
30755
+ bold: true,
30756
+ });
30757
+ // Dirty/clean. Positive framing on clean (success color + ✓), warning
30758
+ // on dirty (warning color + ●). ASCII fallbacks keep the chip
30759
+ // identifiable on dumb terminals.
30760
+ const dirtyChip = input.dirty
30761
+ ? {
30762
+ id: 'dirty',
30763
+ label: theme.ascii ? '* dirty' : '● dirty',
30764
+ color: theme.colors.warning,
30765
+ dim: false,
30766
+ bold: false,
30767
+ }
30768
+ : {
30769
+ id: 'dirty',
30770
+ label: theme.ascii ? '+ clean' : '✓ clean',
30771
+ color: theme.colors.success,
30772
+ dim: false,
30773
+ bold: false,
30774
+ };
30775
+ chips.push(dirtyChip);
30776
+ // Bisect — only when active. Distinct chip so users entering the TUI
30777
+ // mid-bisect see it immediately (#784). Warning color because bisect
30778
+ // is an "in progress, requires user action" state.
30779
+ if (input.bisecting) {
30780
+ chips.push({
30781
+ id: 'bisecting',
30782
+ label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
30783
+ color: theme.colors.warning,
30784
+ dim: false,
30785
+ bold: true,
30786
+ });
30056
30787
  }
30057
- if (cellWidth(value) <= width) {
30058
- return [value];
30788
+ // PR state. When present, the chip uses the PR-state glyph + a short
30789
+ // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
30790
+ // "no PR" chip so users know the system DID look (vs. the bar just
30791
+ // being blank).
30792
+ if (input.pullRequest) {
30793
+ const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
30794
+ const stateLabel = input.pullRequest.isDraft
30795
+ ? 'DRAFT'
30796
+ : input.pullRequest.state.toUpperCase();
30797
+ const label = prGlyph.glyph
30798
+ ? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
30799
+ : `PR #${input.pullRequest.number} ${stateLabel}`;
30800
+ chips.push({
30801
+ id: 'pr',
30802
+ label,
30803
+ color: prGlyph.color,
30804
+ dim: prGlyph.dim,
30805
+ bold: false,
30806
+ });
30059
30807
  }
30060
- const lines = [];
30061
- let current = '';
30062
- let currentWidth = 0;
30063
- const flush = () => {
30064
- if (current.length > 0) {
30065
- lines.push(current);
30066
- current = '';
30067
- currentWidth = 0;
30068
- }
30069
- };
30070
- // Tokenize into runs of whitespace + non-whitespace so we can keep word
30071
- // boundaries when possible.
30072
- const tokens = value.match(/\s+|\S+/g) || [];
30073
- for (const token of tokens) {
30074
- const tokenWidth = cellWidth(token);
30075
- if (currentWidth + tokenWidth <= width) {
30076
- current += token;
30077
- currentWidth += tokenWidth;
30078
- continue;
30079
- }
30080
- if (/^\s+$/.test(token)) {
30081
- // Drop boundary whitespace at line breaks.
30082
- flush();
30083
- continue;
30084
- }
30085
- flush();
30086
- if (tokenWidth <= width) {
30087
- current = token;
30088
- currentWidth = tokenWidth;
30089
- continue;
30090
- }
30091
- // Word longer than budget — hard-split into chunks.
30092
- let remaining = token;
30093
- while (cellWidth(remaining) > width) {
30094
- let chunk = '';
30095
- let chunkWidth = 0;
30096
- for (const character of Array.from(remaining)) {
30097
- const charW = characterWidth(character);
30098
- if (chunkWidth + charW > width)
30099
- break;
30100
- chunk += character;
30101
- chunkWidth += charW;
30102
- }
30103
- lines.push(chunk);
30104
- remaining = remaining.slice(chunk.length);
30105
- }
30106
- if (remaining.length > 0) {
30107
- current = remaining;
30108
- currentWidth = cellWidth(remaining);
30109
- }
30808
+ else {
30809
+ chips.push({
30810
+ id: 'pr',
30811
+ label: theme.ascii ? '- no PR' : '⊘ no PR',
30812
+ color: theme.colors.muted,
30813
+ dim: true,
30814
+ bold: false,
30815
+ });
30110
30816
  }
30111
- flush();
30112
- return lines.length > 0 ? lines : [value];
30113
- }
30114
- function truncateCells(value, width) {
30115
- if (width < 1) {
30116
- return '';
30817
+ // View breadcrumb. Rendered only when there's content (`coco ui`
30818
+ // root view no breadcrumb chip; pushed into a sub-view → chip
30819
+ // appears). Comes AFTER PR so the "state" group (app/repo/branch/
30820
+ // dirty/PR) reads as one cluster and the "navigation" group (view
30821
+ // breadcrumb / loading) reads as a separate cluster.
30822
+ if (input.breadcrumb) {
30823
+ chips.push({
30824
+ id: 'view',
30825
+ label: input.breadcrumb,
30826
+ color: theme.colors.muted,
30827
+ dim: true,
30828
+ bold: false,
30829
+ });
30117
30830
  }
30118
- if (cellWidth(value) <= width) {
30119
- return value;
30831
+ if (input.loading) {
30832
+ chips.push({
30833
+ id: 'loading',
30834
+ label: input.loading.trim(),
30835
+ color: theme.colors.muted,
30836
+ dim: true,
30837
+ bold: false,
30838
+ });
30120
30839
  }
30121
- const suffix = width > 3 ? '...' : '';
30122
- const available = width - cellWidth(suffix);
30123
- let used = 0;
30124
- let output = '';
30125
- for (const character of Array.from(value)) {
30126
- const nextWidth = characterWidth(character);
30127
- if (used + nextWidth > available) {
30128
- break;
30129
- }
30130
- output += character;
30131
- used += nextWidth;
30840
+ // Mode the explicit input-mode indicator (#P2.2). Always present
30841
+ // so users never wonder why `q` doesn't quit while they're editing.
30842
+ // EDIT / FILTER use the warning color to signal "your keystrokes
30843
+ // mean something different right now"; NORMAL uses accent (matches
30844
+ // the app chip's home base).
30845
+ const modeColor = input.mode === 'NORMAL'
30846
+ ? theme.colors.accent
30847
+ : theme.colors.warning;
30848
+ chips.push({
30849
+ id: 'mode',
30850
+ label: `[${input.mode}]`,
30851
+ color: modeColor,
30852
+ dim: false,
30853
+ bold: true,
30854
+ });
30855
+ // Search — only when active. Dim so it doesn't compete with the
30856
+ // identity chips for attention; the user knows it's there because
30857
+ // they're typing into it.
30858
+ if (input.search) {
30859
+ chips.push({
30860
+ id: 'search',
30861
+ label: input.search,
30862
+ color: theme.colors.muted,
30863
+ dim: true,
30864
+ bold: false,
30865
+ });
30132
30866
  }
30133
- return `${output}${suffix}`;
30867
+ return chips;
30868
+ }
30869
+ /**
30870
+ * Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
30871
+ * between every pair. Used by the consumer to decide whether the
30872
+ * chip layout fits the column budget or whether to fall back to the
30873
+ * single-fragment truncated path.
30874
+ */
30875
+ function measureHeaderChipsWidth(chips) {
30876
+ if (chips.length === 0)
30877
+ return 0;
30878
+ const labels = chips.map((chip) => cellWidth(chip.label));
30879
+ const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
30880
+ return labels.reduce((sum, w) => sum + w, 0) + separators;
30134
30881
  }
30135
30882
 
30136
30883
  /**
30137
- * Title-bar renderer. Surfaces:
30138
- * - the app label (e.g. "coco ui")
30139
- * - current repo owner/name (or "local repository")
30140
- * - current branch + dirty / BISECTING flag
30141
- * - PR glyph + label when one is detected
30142
- * - breadcrumb of the view stack
30143
- * - loading hint for boot / context fetches
30144
- * - mode indicator: [NORMAL] / [EDIT] / [FILTER]
30145
- * - active filter / search input
30884
+ * Title-bar renderer. Surfaces the workstation's identity + navigation
30885
+ * state as a row of small visually-distinct chips:
30146
30886
  *
30147
- * Truncation: when the assembled title overruns the available columns we
30148
- * fall back to a single-fragment Text (truncating the joined string) so
30149
- * the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
30150
- * glyph in its own colored span when there's headroom.
30887
+ * coco · gfargo/coco · main · clean · ⊘ no PR · [NORMAL]
30151
30888
  *
30152
- * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
30153
- * of #890. No behavior change.
30889
+ * Per-chip color/glyph treatment lets the user scan in chunks ("what
30890
+ * app, what repo, what branch, how clean, what PR state, what mode")
30891
+ * instead of parsing one long sentence. Chip construction is in
30892
+ * `chrome/headerChips.ts`; this runtime just renders.
30893
+ *
30894
+ * Truncation: when the assembled chip row overruns the available
30895
+ * columns we fall back to a single Text fragment (truncating the
30896
+ * joined chip labels) so the ellipsis can't land mid-glyph. This is
30897
+ * the same defensive pattern the pre-redesign single-fragment code
30898
+ * used, applied at the chip-list level instead of the inline glyph
30899
+ * split.
30900
+ *
30901
+ * Extracted from `src/commands/log/inkRuntime.ts` as part of phase
30902
+ * 5a.7 of #890. Chip restructuring introduced post-0.54.2.
30154
30903
  */
30155
30904
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
30156
30905
  const { Box, Text } = components;
30906
+ // Pull the source state into the small "describe what to render"
30907
+ // shape the chip builder expects. Keeps the runtime decoupled from
30908
+ // the chip layout — the builder doesn't know about LogInkState /
30909
+ // LogInkContext, just plain values.
30157
30910
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
30158
- // #784 surface bisect-in-progress in the title bar so users entering
30159
- // the TUI mid-bisect see it immediately, before they navigate to gB.
30160
- const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
30161
- const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
30911
+ const dirty = Boolean(context.branches?.dirty);
30912
+ const bisecting = Boolean(context.bisect?.active);
30162
30913
  const repo = context.provider?.repository.owner && context.provider.repository.name
30163
30914
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
30164
30915
  : 'local repository';
30165
30916
  const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
30166
- const prGlyph = prInfo ? getPullRequestStateGlyph(prInfo, theme) : null;
30167
- const prLabel = prInfo
30168
- ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
30169
- : 'no PR';
30170
- const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
30171
- // Boot loading wins over the per-context loading hint because it
30172
- // tells the user the headline thing they care about (commits aren't
30173
- // ready yet) — the context fetches finish independently and surface
30174
- // their own per-section loading copy in the sidebars.
30917
+ // Boot loading wins over the per-context loading hint — same
30918
+ // priority as pre-redesign. Context fetches still surface their own
30919
+ // copy in the sidebars.
30175
30920
  const loading = state.bootLoading
30176
- ? ' loading commits'
30177
- : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
30921
+ ? 'loading commits'
30922
+ : isLogInkContextLoading(contextStatus) ? 'loading context' : '';
30178
30923
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
30179
30924
  const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
30180
- // Repo breadcrumb (when nested) comes first so the user sees which
30181
- // submodule they're in at a glance, then the view breadcrumb (when
30182
- // pushed deeper than the root view). The truncate fallback in the
30183
- // title row still applies — when both fight for space, the ellipsis
30184
- // lands at the end of whichever segment overflows.
30185
30925
  const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
30186
- // Mode indicator (P2.2) — surfaces the current input mode so users
30187
- // never wonder why `q` doesn't quit while they're editing or filtering.
30188
30926
  const mode = state.commitCompose.editing
30189
- ? '[EDIT]'
30927
+ ? 'EDIT'
30190
30928
  : state.filterMode
30191
- ? '[FILTER]'
30192
- : '[NORMAL]';
30193
- const titlePrefix = `${appLabel} ${repo} ${branch} ${dirty} `;
30194
- const glyphPart = prGlyph?.glyph ? `${prGlyph.glyph} ` : '';
30195
- const titleSuffix = `${view}${loading}`;
30196
- const fullTitle = `${titlePrefix}${glyphPart}${prLabel}${titleSuffix}`;
30197
- const titleBudget = columns - mode.length - 4;
30198
- const truncatedTitle = truncateCells(fullTitle, titleBudget);
30199
- // Only split into colored fragments when the prefix + glyph + label all
30200
- // fit unmodified — otherwise the truncate ellipsis can land mid-fragment
30201
- // and we'd render half a glyph in the wrong color.
30202
- const splitFragments = truncatedTitle === fullTitle && glyphPart.length > 0;
30203
- const modeColor = theme.noColor
30204
- ? undefined
30205
- : state.filterMode || state.commitCompose.editing
30206
- ? theme.colors.warning
30207
- : theme.colors.accent;
30929
+ ? 'FILTER'
30930
+ : 'NORMAL';
30931
+ const search = state.filterMode
30932
+ ? `search: ${state.filter}_`
30933
+ : state.filter
30934
+ ? `filter: ${state.filter}`
30935
+ : '';
30936
+ const chips = buildHeaderChips({
30937
+ appLabel,
30938
+ repo,
30939
+ branch,
30940
+ dirty,
30941
+ bisecting,
30942
+ pullRequest: prInfo ? {
30943
+ number: prInfo.number,
30944
+ state: prInfo.state,
30945
+ isDraft: prInfo.isDraft,
30946
+ } : undefined,
30947
+ breadcrumb: view,
30948
+ loading,
30949
+ mode,
30950
+ search: search ? truncateCells(search, 36) : '',
30951
+ theme,
30952
+ });
30953
+ // Truncation budget. Header line gets the full terminal width minus
30954
+ // the box's horizontal padding (2 cells) and a small safety margin.
30955
+ const budget = Math.max(0, columns - 4);
30956
+ const chipsWidth = measureHeaderChipsWidth(chips);
30208
30957
  return h(Box, {
30209
30958
  borderColor: theme.colors.border,
30210
30959
  borderStyle: theme.borderStyle,
30211
30960
  height: 3,
30212
30961
  paddingX: 1,
30213
- }, splitFragments
30214
- ? h(Text, { bold: true, color: theme.colors.accent }, titlePrefix)
30215
- : h(Text, { bold: true, color: theme.colors.accent }, truncatedTitle), splitFragments
30216
- ? h(Text, { bold: true, color: prGlyph?.color, dimColor: prGlyph?.dim }, glyphPart)
30217
- : undefined, splitFragments
30218
- ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
30219
- : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncateCells(search, 36)}`) : undefined);
30962
+ }, chipsWidth <= budget
30963
+ ? renderChipRow(h, Text, chips)
30964
+ : renderFallback(h, Text, chips, theme, budget));
30965
+ }
30966
+ /**
30967
+ * Render every chip as its own Text span with its own color/style,
30968
+ * interleaved with dim separator spans. This is the path used when
30969
+ * everything fits — the eye gets the full chip treatment.
30970
+ */
30971
+ function renderChipRow(h, Text, chips) {
30972
+ const nodes = [];
30973
+ chips.forEach((chip, index) => {
30974
+ if (index > 0) {
30975
+ // Separator is intentionally dim so the eye can use it as a
30976
+ // visual delimiter without it competing with chip labels for
30977
+ // attention.
30978
+ nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
30979
+ }
30980
+ nodes.push(h(Text, {
30981
+ key: chip.id,
30982
+ color: chip.color,
30983
+ dimColor: chip.dim,
30984
+ bold: chip.bold,
30985
+ }, chip.label));
30986
+ });
30987
+ return nodes;
30988
+ }
30989
+ /**
30990
+ * Fallback path for narrow terminals. Concatenates every chip label
30991
+ * with separators, then truncates the whole string with
30992
+ * `truncateCells` so the ellipsis lands at a cell boundary. Loses the
30993
+ * per-chip color treatment in exchange for guaranteed legibility on
30994
+ * narrow displays — the same trade-off the pre-redesign single-
30995
+ * fragment code made for its inline glyph color split.
30996
+ */
30997
+ function renderFallback(h, Text, chips, theme, budget) {
30998
+ const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
30999
+ return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
30220
31000
  }
30221
31001
 
30222
31002
  /**
@@ -30439,10 +31219,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
30439
31219
  const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
30440
31220
  const fileRows = worktree.files.slice(0, 12).map((file, index) => {
30441
31221
  const codes = `${file.indexStatus}${file.worktreeStatus}`;
31222
+ // Smart path truncation: keep the leading status codes and elide
31223
+ // middle directory segments to preserve the filename. Falls back
31224
+ // to plain truncation when the codes + a meaningful filename
31225
+ // don't both fit. Same shape as the detail surface so all the
31226
+ // status-row renderings elide consistently.
31227
+ const prefix = ` ${codes} `;
31228
+ const totalBudget = width - 4;
31229
+ const pathBudget = totalBudget - cellWidth(prefix);
31230
+ const label = pathBudget >= 8
31231
+ ? `${prefix}${truncatePathCells(file.path, pathBudget)}`
31232
+ : truncateCells(`${prefix}${file.path}`, totalBudget);
30442
31233
  return h(Text, {
30443
31234
  key: `tab-status-file-${index}`,
30444
31235
  color: colorOf(file.state),
30445
- }, truncateCells(` ${codes} ${file.path}`, width - 4));
31236
+ }, label);
30446
31237
  });
30447
31238
  return [
30448
31239
  summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
@@ -31785,7 +32576,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
31785
32576
  color: theme.noColor ? undefined : theme.colors.accent,
31786
32577
  backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
31787
32578
  inverse: isActive && focused,
31788
- }, truncateCells(`${arrow}${headerFile.path}`, width - 4));
32579
+ }, (() => {
32580
+ // Smart path truncation for the diff file header: keep
32581
+ // the leading arrow glyph and elide middle path
32582
+ // segments so the filename is never lost. Falls back to
32583
+ // plain truncation when there isn't room for a
32584
+ // meaningful filename.
32585
+ const pathBudget = (width - 4) - cellWidth(arrow);
32586
+ return pathBudget >= 8
32587
+ ? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
32588
+ : truncateCells(`${arrow}${headerFile.path}`, width - 4);
32589
+ })());
31789
32590
  }
31790
32591
  return h(Text, {
31791
32592
  key: `stash-diff-line-${absoluteIndex}`,
@@ -35266,6 +36067,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
35266
36067
  });
35267
36068
  return out;
35268
36069
  }
36070
+ /**
36071
+ * Compose a `<prefix><path><suffix>` line where the path gets smart
36072
+ * middle-elision truncation if needed, while the fixed prefix/suffix
36073
+ * decorations stay intact. Falls back to plain whole-line truncation
36074
+ * when the suffix decorations consume too much of the budget for the
36075
+ * path-aware variant to leave a meaningful filename.
36076
+ *
36077
+ * Used by the changed-files list AND the compose-context staged /
36078
+ * unstaged sections so all three places elide identically — same
36079
+ * floor (8 cells), same fallback shape.
36080
+ */
36081
+ function smartPathLabel(prefix, path, suffix, totalBudget) {
36082
+ const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
36083
+ if (pathBudget >= 8) {
36084
+ return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
36085
+ }
36086
+ return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
36087
+ }
35269
36088
  /**
35270
36089
  * Render a list of changed files with status-code colors and stats. Used
35271
36090
  * by both the history inspector and the commit-diff detail panel so the
@@ -35293,13 +36112,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
35293
36112
  // in `lfsPointer.ts` so even rename / mode-only rows are
35294
36113
  // flagged.
35295
36114
  const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
35296
- const label = `${cursor} ${statusCode} ${file.path}${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36115
+ // Smart path truncation via `smartPathLabel`: keeps the cursor +
36116
+ // status-code prefix and the stats/badge suffix intact, gives
36117
+ // the path's remaining width budget to middle-elision so the
36118
+ // filename survives instead of getting blunt-truncated off the
36119
+ // end (the issue users hit when inspector paths read like
36120
+ // `src/commands/log/da...`).
36121
+ const labelPrefix = `${cursor} ${statusCode} `;
36122
+ const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
36123
+ const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
35297
36124
  return h(Text, {
35298
36125
  key: `commit-file-${index}`,
35299
36126
  color: statusCodeColor(file.status, theme),
35300
36127
  inverse: isSelected && focused && !theme.noColor,
35301
36128
  bold: isSelected,
35302
- }, truncateCells(label, width - 4));
36129
+ }, label);
35303
36130
  });
35304
36131
  }
35305
36132
  function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
@@ -35575,7 +36402,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35575
36402
  ...stagedFiles.map((file, index) => h(Text, {
35576
36403
  key: `compose-context-staged-${index}`,
35577
36404
  color: theme.noColor ? undefined : theme.colors.gitAdded,
35578
- }, truncateCells(` ${file.indexStatus} ${file.path}`, width - 4))),
36405
+ }, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
35579
36406
  h(Text, { key: 'compose-context-staged-spacer' }, ''),
35580
36407
  ]
35581
36408
  : []), ...(unstagedFiles.length
@@ -35584,7 +36411,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
35584
36411
  ...unstagedFiles.map((file, index) => h(Text, {
35585
36412
  key: `compose-context-unstaged-${index}`,
35586
36413
  color: theme.noColor ? undefined : theme.colors.gitModified,
35587
- }, truncateCells(` ${file.worktreeStatus} ${file.path}`, width - 4))),
36414
+ }, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
35588
36415
  ]
35589
36416
  : !stagedFiles.length && !loadingWorktree
35590
36417
  ? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
@@ -36251,6 +37078,14 @@ function LogInkApp(deps) {
36251
37078
  // workdirs for submodule paths recorded in `.gitmodules` (which
36252
37079
  // are repo-relative). Undefined during the brief moment between
36253
37080
  // git swap and the revparse callback resolving.
37081
+ //
37082
+ // Audit finding #10: rapid frame push/pop races are prevented by
37083
+ // the per-effect `cancelled` flag — React fires the cleanup
37084
+ // synchronously BEFORE running the next effect body, so any
37085
+ // pending revparse from the old `git` sees `cancelled === true`
37086
+ // and skips its write. The `git` reference itself is captured by
37087
+ // closure, so each effect run resolves against the right binding.
37088
+ // No additional depth tagging is needed.
36254
37089
  const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
36255
37090
  React.useEffect(() => {
36256
37091
  let cancelled = false;
@@ -36476,7 +37311,7 @@ function LogInkApp(deps) {
36476
37311
  if (cancelled || !mountedRef.current)
36477
37312
  return;
36478
37313
  const message = error instanceof Error ? error.message : String(error);
36479
- dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
37314
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
36480
37315
  dispatch({ type: 'setBootLoading', value: false });
36481
37316
  });
36482
37317
  return () => {
@@ -36544,8 +37379,15 @@ function LogInkApp(deps) {
36544
37379
  ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
36545
37380
  ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
36546
37381
  };
37382
+ // Stash commits as graph roots so post-operation refreshes
37383
+ // keep the same rich graph the boot loader assembled. Without
37384
+ // this, every commit / split-apply / etc. would drop stash
37385
+ // anchors and the cursor-syncs-history effect would degrade
37386
+ // back to "tip not in loaded window" for older stashes.
37387
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
36547
37388
  const fresh = await getLogRows(git, mergedArgv, {
36548
37389
  limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
37390
+ extraRefs: stashHashes,
36549
37391
  });
36550
37392
  if (mountedRef.current && fresh) {
36551
37393
  dispatch({ type: 'replaceRows', rows: fresh });
@@ -36681,18 +37523,50 @@ function LogInkApp(deps) {
36681
37523
  })();
36682
37524
  return () => { cancelled = true; };
36683
37525
  }, [git, dispatch]);
37526
+ // Audit finding #2: re-resolve the repo root inline on every save
37527
+ // and key the deps off `git` + the saved value. The original
37528
+ // implementation read from `repoRootRef.current`, which is async-
37529
+ // populated by the resolver effect above and can lag behind a git
37530
+ // swap. After #995's synchronous pop-restore, the parent's freshly
37531
+ // restored sidebar tab was being written into the submodule's
37532
+ // cache because the ref still held the submodule root during the
37533
+ // brief window before the resolver settled.
37534
+ //
37535
+ // The extra `revparse` cost per save is negligible (saves fire
37536
+ // once per user-initiated tab change, not per render) and the
37537
+ // cancellation flag prevents a stale resolution from racing a
37538
+ // newer one in flight.
36684
37539
  React.useEffect(() => {
36685
- const repoRoot = repoRootRef.current;
36686
- if (!repoRoot)
36687
- return;
36688
- saveSidebarTab(repoRoot, state.userSidebarTab);
36689
- }, [state.userSidebarTab]);
37540
+ let cancelled = false;
37541
+ void (async () => {
37542
+ try {
37543
+ const root = (await git.revparse(['--show-toplevel'])).trim();
37544
+ if (cancelled || !root)
37545
+ return;
37546
+ saveSidebarTab(root, state.userSidebarTab);
37547
+ }
37548
+ catch {
37549
+ // Not in a worktree, or revparse failed — silently skip.
37550
+ // The next save attempt will retry.
37551
+ }
37552
+ })();
37553
+ return () => { cancelled = true; };
37554
+ }, [state.userSidebarTab, git]);
36690
37555
  React.useEffect(() => {
36691
- const repoRoot = repoRootRef.current;
36692
- if (!repoRoot)
36693
- return;
36694
- saveDiffViewMode(repoRoot, state.diffViewMode);
36695
- }, [state.diffViewMode]);
37556
+ let cancelled = false;
37557
+ void (async () => {
37558
+ try {
37559
+ const root = (await git.revparse(['--show-toplevel'])).trim();
37560
+ if (cancelled || !root)
37561
+ return;
37562
+ saveDiffViewMode(root, state.diffViewMode);
37563
+ }
37564
+ catch {
37565
+ // Same as above.
37566
+ }
37567
+ })();
37568
+ return () => { cancelled = true; };
37569
+ }, [state.diffViewMode, git]);
36696
37570
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
36697
37571
  // becomes active with diffSource='stash'. Best-effort — empty stashes
36698
37572
  // or read errors fall through to a "no diff" hint at the render site.
@@ -37079,12 +37953,33 @@ function LogInkApp(deps) {
37079
37953
  // fetched yet); a status hint surfaces in that case so the user
37080
37954
  // knows to toggle full graph or load older commits.
37081
37955
  const lastSyncedHashRef = React.useRef(undefined);
37956
+ // Tracks which target hashes we've already anchored a `git log`
37957
+ // fetch on (#1034 follow-up). When the cursor-syncs-history effect
37958
+ // sees a target whose hash isn't in the loaded window AND isn't in
37959
+ // this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
37960
+ // here. After the fetch resolves and rows are appended, the effect
37961
+ // re-fires; if the target STILL isn't loaded the resolver sees the
37962
+ // hash in this set and returns `unreachable` instead of looping.
37963
+ //
37964
+ // Stored as a ref because (a) the resolver only ever reads it and
37965
+ // (b) component re-renders on state.filteredCommits change are the
37966
+ // re-fire trigger; storing here in state would add a redundant
37967
+ // render per attempt.
37968
+ const attemptedContextHashesRef = React.useRef(new Set());
37969
+ const loadCommitContextRef = React.useRef(null);
37082
37970
  React.useEffect(() => {
37083
37971
  const onBranchTab = state.activeView === 'branches' ||
37084
37972
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37085
37973
  const onTagTab = state.activeView === 'tags' ||
37086
37974
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37087
- if (!onBranchTab && !onTagTab)
37975
+ // User-reported gap: cursoring a stash didn't sync the history
37976
+ // cursor the way cursoring a branch / tag did. Same auto-jump
37977
+ // affordance now extends to stashes; the stash's commit hash IS
37978
+ // the row to land on (stashes are commits living off the
37979
+ // `refs/stash` tree, visible under `--all` / fullGraph).
37980
+ const onStashTab = state.activeView === 'stash' ||
37981
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
37982
+ if (!onBranchTab && !onTagTab && !onStashTab)
37088
37983
  return;
37089
37984
  let targetHash;
37090
37985
  let targetLabel;
@@ -37110,51 +38005,117 @@ function LogInkApp(deps) {
37110
38005
  targetLabel = `tag ${tag.name}`;
37111
38006
  }
37112
38007
  }
37113
- if (!targetHash)
37114
- return;
37115
- // Skip the dispatch + status churn when the cursor hasn't
37116
- // actually changed which commit it's targeting (the case for
37117
- // rapid navigation through a cluster of branches that all point
37118
- // at the same commit). Without this guard the user sees a stream
37119
- // of "Synced history to <branch> tip" status messages even
37120
- // though the history cursor never moved.
37121
- if (targetHash === lastSyncedHashRef.current)
37122
- return;
37123
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
37124
- if (loaded) {
37125
- lastSyncedHashRef.current = targetHash;
37126
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
37127
- // Confirmation status message so the user gets feedback even
37128
- // when the dedicated branches / tags view is occupying the
37129
- // main panel and the history cursor moves invisibly behind it.
37130
- dispatch({
37131
- type: 'setStatus',
37132
- value: `Synced history to ${targetLabel} tip`,
37133
- });
38008
+ else if (onStashTab) {
38009
+ const all = context.stashes?.stashes || [];
38010
+ const visible = state.filter
38011
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
38012
+ : all;
38013
+ const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
38014
+ if (stash) {
38015
+ // Two-step fallback chain for stash cursor sync:
38016
+ //
38017
+ // 1. Try `baseHash` (the branch tip the stash was created
38018
+ // from). This answers the user-visible question "where
38019
+ // in larger git history was this stash made?" — that's
38020
+ // the branch origin point, not the stash's own merge-
38021
+ // commit row off in `refs/stash`. Base commits live on
38022
+ // regular branches so they're almost always in the
38023
+ // loaded window.
38024
+ //
38025
+ // 2. If `baseHash` isn't in the loaded window (the stash's
38026
+ // base branch was deleted, or the base is older than
38027
+ // the 1000-commit cap), fall back to `stash.hash`
38028
+ // itself. The stash commit was added as an extraRef so
38029
+ // it's reachable from the graph if it fits the window.
38030
+ //
38031
+ // Only after BOTH miss does the effect report "tip not in
38032
+ // loaded window." The label flips to mention "base" vs the
38033
+ // stash commit so the user knows what they're looking at.
38034
+ // hashesMatchAny handles the short-hash auto-extension
38035
+ // mismatch between `git stash list --format=%h` (stash hash)
38036
+ // and `git log --pretty=format:%h` (history row). Same
38037
+ // hazard as the branch/tag cursor sync — see src/git/hashes.ts.
38038
+ const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
38039
+ const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
38040
+ if (baseLoaded) {
38041
+ targetHash = stash.baseHash;
38042
+ targetLabel = `${stash.ref}'s base`;
38043
+ }
38044
+ else if (hashLoaded) {
38045
+ targetHash = stash.hash;
38046
+ targetLabel = stash.ref;
38047
+ }
38048
+ else {
38049
+ // Neither in window — set to baseHash so the standard
38050
+ // "not in loaded window" message fires with a meaningful
38051
+ // label (the base is what the user actually wants to see).
38052
+ targetHash = stash.baseHash || stash.hash;
38053
+ targetLabel = stash.ref;
38054
+ }
38055
+ }
37134
38056
  }
37135
- else {
37136
- dispatch({
37137
- type: 'setStatus',
37138
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
37139
- });
38057
+ // Delegate the actual decision to the pure resolver so the
38058
+ // logic is testable in isolation. The effect just performs the
38059
+ // resolver's chosen action.
38060
+ const decision = resolveCursorSyncDecision({
38061
+ target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
38062
+ loadedHashes: buildLoadedHashSet(state.filteredCommits),
38063
+ lastSyncedHash: lastSyncedHashRef.current,
38064
+ attemptedContextHashes: attemptedContextHashesRef.current,
38065
+ });
38066
+ switch (decision.type) {
38067
+ case 'noop':
38068
+ return;
38069
+ case 'jump':
38070
+ lastSyncedHashRef.current = decision.hash;
38071
+ dispatch({ type: 'selectCommitByHash', hash: decision.hash });
38072
+ dispatch({
38073
+ type: 'setStatus',
38074
+ value: `Synced history to ${decision.label} tip`,
38075
+ });
38076
+ return;
38077
+ case 'load-context':
38078
+ // Mark the hash as attempted BEFORE firing the load so a
38079
+ // re-fire of this effect (state.filteredCommits change while
38080
+ // the load is in flight) doesn't kick off a duplicate
38081
+ // request. The resolver sees the hash in the set and
38082
+ // returns `noop` until the load completes; on completion the
38083
+ // appendRows triggers a final re-fire that either jumps or
38084
+ // returns `unreachable`.
38085
+ attemptedContextHashesRef.current.add(decision.target.hash);
38086
+ void loadCommitContextRef.current?.(decision.target);
38087
+ return;
38088
+ case 'unreachable':
38089
+ dispatch({
38090
+ type: 'setStatus',
38091
+ value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
38092
+ kind: 'warning',
38093
+ });
38094
+ return;
37140
38095
  }
37141
38096
  }, [
37142
- dispatch, context.branches, context.tags,
38097
+ dispatch, context.branches, context.tags, context.stashes,
37143
38098
  state.activeView, state.focus, state.sidebarTab,
37144
- state.selectedBranchIndex, state.selectedTagIndex,
38099
+ state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
37145
38100
  state.branchSort, state.tagSort, state.filter,
37146
38101
  state.filteredCommits,
37147
38102
  ]);
37148
38103
  // Reset the dedup ref when the user moves focus away from the
37149
- // sidebar branches / tags tab so re-entering re-fires the sync
37150
- // even if the cursored branch is the same as before.
38104
+ // sidebar branches / tags / stashes tab so re-entering re-fires the
38105
+ // sync even if the cursored row is the same as before.
37151
38106
  React.useEffect(() => {
37152
38107
  const onBranchTab = state.activeView === 'branches' ||
37153
38108
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
37154
38109
  const onTagTab = state.activeView === 'tags' ||
37155
38110
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
37156
- if (!onBranchTab && !onTagTab) {
38111
+ const onStashTab = state.activeView === 'stash' ||
38112
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
38113
+ if (!onBranchTab && !onTagTab && !onStashTab) {
37157
38114
  lastSyncedHashRef.current = undefined;
38115
+ // Drop any context-load attempt tracking too. If the user
38116
+ // navigates back later we want to retry rather than show
38117
+ // "unreachable" based on a stale attempted-set.
38118
+ attemptedContextHashesRef.current = new Set();
37158
38119
  }
37159
38120
  }, [state.activeView, state.focus, state.sidebarTab]);
37160
38121
  React.useEffect(() => {
@@ -37185,7 +38146,7 @@ function LogInkApp(deps) {
37185
38146
  ]);
37186
38147
  const toggleSelectedFileStage = React.useCallback(async () => {
37187
38148
  if (!selectedWorktreeFile) {
37188
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38149
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37189
38150
  return;
37190
38151
  }
37191
38152
  dispatch({ type: 'setStatus', value: 'updating file stage state' });
@@ -37200,7 +38161,7 @@ function LogInkApp(deps) {
37200
38161
  const toggleSelectedHunkStage = React.useCallback(async () => {
37201
38162
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37202
38163
  if (!selectedHunk) {
37203
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38164
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37204
38165
  return;
37205
38166
  }
37206
38167
  dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
@@ -37214,6 +38175,7 @@ function LogInkApp(deps) {
37214
38175
  dispatch({
37215
38176
  type: 'setStatus',
37216
38177
  value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
38178
+ kind: 'success',
37217
38179
  });
37218
38180
  await refreshWorktreeContext();
37219
38181
  setWorktreeDiff(undefined);
@@ -37223,12 +38185,13 @@ function LogInkApp(deps) {
37223
38185
  dispatch({
37224
38186
  type: 'setStatus',
37225
38187
  value: error.message || 'failed to update hunk stage state',
38188
+ kind: 'error',
37226
38189
  });
37227
38190
  }
37228
38191
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37229
38192
  const revertSelectedFile = React.useCallback(async () => {
37230
38193
  if (!selectedWorktreeFile) {
37231
- dispatch({ type: 'setStatus', value: 'no worktree file selected' });
38194
+ dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
37232
38195
  return;
37233
38196
  }
37234
38197
  dispatch({ type: 'setStatus', value: 'reverting selected file' });
@@ -37241,13 +38204,13 @@ function LogInkApp(deps) {
37241
38204
  const revertSelectedHunk = React.useCallback(async () => {
37242
38205
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
37243
38206
  if (!selectedHunk) {
37244
- dispatch({ type: 'setStatus', value: 'no hunk selected' });
38207
+ dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
37245
38208
  return;
37246
38209
  }
37247
38210
  dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
37248
38211
  try {
37249
38212
  await revertHunk(git, selectedHunk);
37250
- dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}` });
38213
+ dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
37251
38214
  await refreshWorktreeContext();
37252
38215
  setWorktreeDiff(undefined);
37253
38216
  setWorktreeHunks(undefined);
@@ -37256,13 +38219,14 @@ function LogInkApp(deps) {
37256
38219
  dispatch({
37257
38220
  type: 'setStatus',
37258
38221
  value: error.message || 'failed to revert hunk',
38222
+ kind: 'error',
37259
38223
  });
37260
38224
  }
37261
38225
  }, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
37262
38226
  const createCommitFromCompose = React.useCallback(async () => {
37263
38227
  const stagedCount = context.worktree?.stagedCount || 0;
37264
38228
  if (!stagedCount) {
37265
- dispatch({ type: 'setStatus', value: 'stage changes before committing' });
38229
+ dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
37266
38230
  return;
37267
38231
  }
37268
38232
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
@@ -37326,6 +38290,12 @@ function LogInkApp(deps) {
37326
38290
  git,
37327
38291
  signal: controller.signal,
37328
38292
  onStreamChunk: (_text, accumulated) => {
38293
+ // Audit finding #4: skip dispatching into a torn-down
38294
+ // tree. If the user quit (or otherwise unmounted the
38295
+ // workstation) mid-stream, React warns about updates on
38296
+ // an unmounted component. Drop the chunk silently.
38297
+ if (!mountedRef.current)
38298
+ return;
37329
38299
  // Dispatch the full accumulated text — the preview chrome
37330
38300
  // helper does the last-N-lines slicing at render time, so
37331
38301
  // re-doing the slice here would be wasted work. Per-chunk
@@ -37337,18 +38307,23 @@ function LogInkApp(deps) {
37337
38307
  });
37338
38308
  },
37339
38309
  });
38310
+ // Audit finding #4 (unmount race): bail out before any
38311
+ // post-await dispatch if the user quit while the LLM call was
38312
+ // in flight. Same pattern as `refreshHistoryRows` upstream.
38313
+ if (!mountedRef.current)
38314
+ return;
37340
38315
  // Cancel path (#881 phase 3). User pressed Esc during the
37341
38316
  // stream; reducer drops loading + preview, status line shows
37342
38317
  // a neutral "cancelled" message. Skip the result / failure
37343
38318
  // dispatches because the user already knows what happened.
37344
38319
  if (result.cancelled) {
37345
38320
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37346
- dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
38321
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
37347
38322
  return;
37348
38323
  }
37349
38324
  if (result.ok && result.draft) {
37350
38325
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37351
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
38326
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
37352
38327
  return;
37353
38328
  }
37354
38329
  dispatch({
@@ -37357,6 +38332,23 @@ function LogInkApp(deps) {
37357
38332
  });
37358
38333
  dispatch({ type: 'setStatus', value: result.message });
37359
38334
  }
38335
+ catch (error) {
38336
+ // Audit finding #3: defensive recovery for unexpected throws
38337
+ // from the workflow. The workflow catches its own errors
38338
+ // today, so this catch is latent — but any future refactor
38339
+ // that lets an error escape would otherwise strand the
38340
+ // spinner permanently with no user-facing recovery short of
38341
+ // quitting. Surface a generic failure and clear the loading
38342
+ // state so the user can re-try.
38343
+ if (mountedRef.current) {
38344
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
38345
+ dispatch({
38346
+ type: 'setStatus',
38347
+ value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
38348
+ kind: 'error',
38349
+ });
38350
+ }
38351
+ }
37360
38352
  finally {
37361
38353
  // Clear the ref only if it still points at OUR controller — a
37362
38354
  // rapid second invocation could have already replaced it, in
@@ -37415,7 +38407,7 @@ function LogInkApp(deps) {
37415
38407
  const startCreatePullRequest = React.useCallback(async () => {
37416
38408
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37417
38409
  if (!head) {
37418
- dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
38410
+ dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
37419
38411
  return;
37420
38412
  }
37421
38413
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37423,11 +38415,12 @@ function LogInkApp(deps) {
37423
38415
  dispatch({
37424
38416
  type: 'setStatus',
37425
38417
  value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
38418
+ kind: 'warning',
37426
38419
  });
37427
38420
  return;
37428
38421
  }
37429
38422
  if (head === defaultBranch) {
37430
- dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.` });
38423
+ dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
37431
38424
  return;
37432
38425
  }
37433
38426
  if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
@@ -37437,6 +38430,7 @@ function LogInkApp(deps) {
37437
38430
  value: existing
37438
38431
  ? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
37439
38432
  : `A pull request is already open for ${head}.`,
38433
+ kind: 'warning',
37440
38434
  });
37441
38435
  return;
37442
38436
  }
@@ -37448,9 +38442,14 @@ function LogInkApp(deps) {
37448
38442
  const cancelHandle = { cancelled: false };
37449
38443
  pullRequestBodyCancelRef.current = cancelHandle;
37450
38444
  dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
38445
+ // Audit finding #6: soft cancel today — Esc skips opening the
38446
+ // follow-up prompt, but the LLM call itself keeps running to
38447
+ // completion (no AbortSignal threaded through the changelog CLI
38448
+ // chain). Status copy reflects that honestly so the user isn't
38449
+ // misled into thinking they're saving tokens.
37451
38450
  dispatch({
37452
38451
  type: 'setStatus',
37453
- value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
38452
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
37454
38453
  loading: true,
37455
38454
  });
37456
38455
  try {
@@ -37472,11 +38471,20 @@ function LogInkApp(deps) {
37472
38471
  const initialBody = body.body || '';
37473
38472
  const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37474
38473
  if (!body.ok) {
37475
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
38474
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
37476
38475
  }
37477
38476
  else {
37478
- dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37479
- }
38477
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
38478
+ }
38479
+ // Audit finding #11: clear the pending flag BEFORE opening the
38480
+ // prompt. If a future refactor adds an `await` between the flag
38481
+ // clear (currently in `finally`) and the `openInputPrompt`
38482
+ // dispatch, an Esc keystroke in the gap would dispatch
38483
+ // `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
38484
+ // the prompt visible with a stale "cancelled" message. Clearing
38485
+ // here moves the flag teardown into the same React batch as the
38486
+ // prompt open, eliminating the race.
38487
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37480
38488
  dispatch({
37481
38489
  type: 'openInputPrompt',
37482
38490
  kind: 'create-pr',
@@ -37486,11 +38494,14 @@ function LogInkApp(deps) {
37486
38494
  });
37487
38495
  }
37488
38496
  finally {
37489
- // Clear the flag + the ref so a subsequent draft starts clean.
38497
+ // Belt-and-suspenders: the `try` block clears the flag on the
38498
+ // success path (audit finding #11). This duplicate clear handles
38499
+ // the error / cancel paths where the early-returns skip the
38500
+ // success-path dispatch. Safe to no-op when already false.
38501
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37490
38502
  // Only clear the ref if we still own it — a second invocation
37491
38503
  // would have already taken ownership in which case the cancel
37492
38504
  // duty has rolled over.
37493
- dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37494
38505
  if (pullRequestBodyCancelRef.current === cancelHandle) {
37495
38506
  pullRequestBodyCancelRef.current = null;
37496
38507
  }
@@ -37530,17 +38541,18 @@ function LogInkApp(deps) {
37530
38541
  const yankText = React.useCallback(async (value, label) => {
37531
38542
  const clipboard = clipboardRunner || defaultClipboardRunner;
37532
38543
  if (!value) {
37533
- dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.` });
38544
+ dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
37534
38545
  return;
37535
38546
  }
37536
38547
  try {
37537
38548
  await clipboard(value);
37538
- dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.` });
38549
+ dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
37539
38550
  }
37540
38551
  catch (error) {
37541
38552
  dispatch({
37542
38553
  type: 'setStatus',
37543
38554
  value: `Copy failed (${label}): ${error.message}`,
38555
+ kind: 'error',
37544
38556
  });
37545
38557
  }
37546
38558
  }, [clipboardRunner, dispatch]);
@@ -37564,7 +38576,7 @@ function LogInkApp(deps) {
37564
38576
  const startChangelogView = React.useCallback(async (options = {}) => {
37565
38577
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
37566
38578
  if (!head) {
37567
- dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
38579
+ dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
37568
38580
  return;
37569
38581
  }
37570
38582
  const defaultBranch = context.provider?.repository.defaultBranch;
@@ -37591,6 +38603,11 @@ function LogInkApp(deps) {
37591
38603
  branch: head,
37592
38604
  baseLabel: cached.baseLabel,
37593
38605
  text: cached.text,
38606
+ // Audit finding #9: cache-hit path preserves the original
38607
+ // generation timestamp rather than minting a fresh one — the
38608
+ // "X ago" header should reflect when the LLM ran, not when
38609
+ // the cached entry was re-displayed.
38610
+ generatedAt: cached.generatedAt,
37594
38611
  });
37595
38612
  dispatch({
37596
38613
  type: 'setStatus',
@@ -37611,7 +38628,7 @@ function LogInkApp(deps) {
37611
38628
  baseLabel,
37612
38629
  error: result.message,
37613
38630
  });
37614
- dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}` });
38631
+ dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
37615
38632
  return;
37616
38633
  }
37617
38634
  dispatch({
@@ -37619,10 +38636,14 @@ function LogInkApp(deps) {
37619
38636
  branch: head,
37620
38637
  baseLabel,
37621
38638
  text: result.text,
38639
+ // Audit finding #9: timestamp captured at dispatch time, not
38640
+ // inside the reducer.
38641
+ generatedAt: Date.now(),
37622
38642
  });
37623
38643
  dispatch({
37624
38644
  type: 'setStatus',
37625
38645
  value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
38646
+ kind: 'success',
37626
38647
  });
37627
38648
  }, [
37628
38649
  context.branches?.currentBranch,
@@ -37643,7 +38664,7 @@ function LogInkApp(deps) {
37643
38664
  const yankChangelog = React.useCallback(() => {
37644
38665
  const text = state.changelogView.text;
37645
38666
  if (!text) {
37646
- dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
38667
+ dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
37647
38668
  return;
37648
38669
  }
37649
38670
  void yankText(text, 'changelog');
@@ -37656,7 +38677,7 @@ function LogInkApp(deps) {
37656
38677
  const openChangelogInEditor = React.useCallback(() => {
37657
38678
  const current = state.changelogView.text;
37658
38679
  if (current === undefined) {
37659
- dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
38680
+ dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
37660
38681
  return;
37661
38682
  }
37662
38683
  let dir;
@@ -37667,6 +38688,7 @@ function LogInkApp(deps) {
37667
38688
  dispatch({
37668
38689
  type: 'setStatus',
37669
38690
  value: `Failed to create temp file for editor: ${error.message}`,
38691
+ kind: 'error',
37670
38692
  });
37671
38693
  return;
37672
38694
  }
@@ -37678,6 +38700,7 @@ function LogInkApp(deps) {
37678
38700
  dispatch({
37679
38701
  type: 'setStatus',
37680
38702
  value: `Failed to seed temp file: ${error.message}`,
38703
+ kind: 'error',
37681
38704
  });
37682
38705
  try {
37683
38706
  rmSync(dir, { recursive: true, force: true });
@@ -37701,13 +38724,13 @@ function LogInkApp(deps) {
37701
38724
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37702
38725
  const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
37703
38726
  if (result.error) {
37704
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38727
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37705
38728
  }
37706
38729
  else if (result.signal) {
37707
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38730
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37708
38731
  }
37709
38732
  else if (typeof result.status === 'number' && result.status !== 0) {
37710
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38733
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37711
38734
  }
37712
38735
  else {
37713
38736
  editorOk = true;
@@ -37721,13 +38744,14 @@ function LogInkApp(deps) {
37721
38744
  if (editorOk) {
37722
38745
  try {
37723
38746
  const content = readFileSync$1(file, 'utf8');
37724
- dispatch({ type: 'setChangelogText', text: content });
37725
- dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
38747
+ dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
38748
+ dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
37726
38749
  }
37727
38750
  catch (error) {
37728
38751
  dispatch({
37729
38752
  type: 'setStatus',
37730
38753
  value: `Failed to read back edited changelog: ${error.message}`,
38754
+ kind: 'error',
37731
38755
  });
37732
38756
  }
37733
38757
  }
@@ -37768,19 +38792,19 @@ function LogInkApp(deps) {
37768
38792
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37769
38793
  const result = spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
37770
38794
  if (result.error) {
37771
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38795
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37772
38796
  }
37773
38797
  else if (result.signal) {
37774
38798
  // Editor was killed by a signal (e.g. ^C, SIGTERM). status is
37775
38799
  // null in this case, so the old `status !== 0` check would
37776
38800
  // mistakenly fall through to the success branch.
37777
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38801
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37778
38802
  }
37779
38803
  else if (typeof result.status === 'number' && result.status !== 0) {
37780
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38804
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37781
38805
  }
37782
38806
  else {
37783
- dispatch({ type: 'setStatus', value: `Edited ${path}` });
38807
+ dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
37784
38808
  }
37785
38809
  }
37786
38810
  finally {
@@ -37827,6 +38851,7 @@ function LogInkApp(deps) {
37827
38851
  dispatch({
37828
38852
  type: 'setStatus',
37829
38853
  value: `Failed to create temp file for editor: ${error.message}`,
38854
+ kind: 'error',
37830
38855
  });
37831
38856
  return;
37832
38857
  }
@@ -37838,6 +38863,7 @@ function LogInkApp(deps) {
37838
38863
  dispatch({
37839
38864
  type: 'setStatus',
37840
38865
  value: `Failed to seed temp file: ${error.message}`,
38866
+ kind: 'error',
37841
38867
  });
37842
38868
  try {
37843
38869
  rmSync(dir, { recursive: true, force: true });
@@ -37861,13 +38887,13 @@ function LogInkApp(deps) {
37861
38887
  out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
37862
38888
  const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
37863
38889
  if (result.error) {
37864
- dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
38890
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
37865
38891
  }
37866
38892
  else if (result.signal) {
37867
- dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
38893
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
37868
38894
  }
37869
38895
  else if (typeof result.status === 'number' && result.status !== 0) {
37870
- dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
38896
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
37871
38897
  }
37872
38898
  else {
37873
38899
  editorOk = true;
@@ -37886,12 +38912,13 @@ function LogInkApp(deps) {
37886
38912
  try {
37887
38913
  const content = readFileSync$1(file, 'utf8');
37888
38914
  dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
37889
- dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
38915
+ dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
37890
38916
  }
37891
38917
  catch (error) {
37892
38918
  dispatch({
37893
38919
  type: 'setStatus',
37894
38920
  value: `Failed to read back edited draft: ${error.message}`,
38921
+ kind: 'error',
37895
38922
  });
37896
38923
  }
37897
38924
  }
@@ -37967,7 +38994,7 @@ function LogInkApp(deps) {
37967
38994
  const applyCommitSplit = React.useCallback(async () => {
37968
38995
  const splitPlan = state.splitPlan;
37969
38996
  if (!splitPlan?.plan || !splitPlan.planContext) {
37970
- dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
38997
+ dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
37971
38998
  return;
37972
38999
  }
37973
39000
  // Diagnostic dump for the silent-failure bug surfaced in #944
@@ -38076,7 +39103,8 @@ function LogInkApp(deps) {
38076
39103
  // that could disagree with reality on partial-apply.
38077
39104
  const commitHashes = result.commitHashes || [];
38078
39105
  if (commitHashes.length > 0) {
38079
- dispatch({ type: 'markRecentCommits', hashes: commitHashes });
39106
+ // Audit finding #9: timestamp captured at dispatch time.
39107
+ dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
38080
39108
  // DevSkim: ignore DS172411 — function literal, fixed delay,
38081
39109
  // no caller-supplied data flowing through.
38082
39110
  setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
@@ -38937,7 +39965,7 @@ function LogInkApp(deps) {
38937
39965
  };
38938
39966
  const handler = handlers[id];
38939
39967
  if (!handler) {
38940
- dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired` });
39968
+ dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
38941
39969
  return;
38942
39970
  }
38943
39971
  const result = await handler();
@@ -38982,7 +40010,37 @@ function LogInkApp(deps) {
38982
40010
  // without flickering the surfaces through a 'loading' phase.
38983
40011
  await refreshContext({ silent: true });
38984
40012
  }
38985
- }, [context, dispatch, git, refreshContext, refreshHistoryRows, state.branchSort, state.filter, state.selectedBranchIndex,
40013
+ // Stash workflow follow-up. Two distinct behaviours.
40014
+ //
40015
+ // **apply / pop**: the user brought stashed content back into the
40016
+ // worktree, but the sidebar still has them on the stash view.
40017
+ // Expected next move is "look at what landed in my worktree", so
40018
+ // jump them to history view (where the worktree counts in the
40019
+ // sidebar are visible) AND refresh worktree context explicitly so
40020
+ // the staged / unstaged / untracked numbers reflect the changes.
40021
+ //
40022
+ // **drop**: the silent context refresh above already re-fetched
40023
+ // the stash list, BUT users reported it feeling like nothing
40024
+ // happened. Fix two things: refresh worktree alongside (drops can
40025
+ // affect untracked files when the stash held `-u` state), and
40026
+ // surface the new stash count on the status line so there's
40027
+ // unambiguous feedback that the drop landed and the list shrank.
40028
+ if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
40029
+ dispatch({ type: 'pushView', value: 'history' });
40030
+ await refreshWorktreeContext();
40031
+ }
40032
+ if (result?.ok && id === 'drop-stash') {
40033
+ // Explicit worktree refresh in case the dropped stash carried
40034
+ // untracked-file state that's now collected.
40035
+ await refreshWorktreeContext();
40036
+ // The silent context refresh already replaced `context.stashes`;
40037
+ // reading the count back here would be stale because closures
40038
+ // capture the pre-refresh value. Status message stays generic
40039
+ // ("Dropped stash@{N}") — the visible list shrinking is the
40040
+ // unambiguous signal that the operation landed.
40041
+ }
40042
+ }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
40043
+ state.branchSort, state.filter, state.selectedBranchIndex,
38986
40044
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
38987
40045
  state.statusFilterMask, state.tagSort]);
38988
40046
  // Resolve the active view's "yank target" (commit hash / branch /
@@ -39144,15 +40202,15 @@ function LogInkApp(deps) {
39144
40202
  }
39145
40203
  }
39146
40204
  if (!value || !label) {
39147
- dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
40205
+ dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
39148
40206
  return;
39149
40207
  }
39150
40208
  try {
39151
40209
  await clipboard(value);
39152
- dispatch({ type: 'setStatus', value: `Copied ${label}` });
40210
+ dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
39153
40211
  }
39154
40212
  catch (error) {
39155
- dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}` });
40213
+ dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
39156
40214
  }
39157
40215
  }, [
39158
40216
  clipboardRunner,
@@ -39212,63 +40270,175 @@ function LogInkApp(deps) {
39212
40270
  React.useEffect(() => {
39213
40271
  loadingMoreCommitsRef.current = loadingMoreCommits;
39214
40272
  }, [loadingMoreCommits]);
40273
+ // STABLE useCallback (empty deps) for loadMoreCommits. The function
40274
+ // reads the volatile state (commit counts, fetch args, hasMore) via
40275
+ // refs that update on every render so the identity stays constant.
40276
+ //
40277
+ // Why stable matters: the cursor-syncs-history auto-load chain
40278
+ // calls this through a forward-reference ref (loadMoreCommitsRef).
40279
+ // If loadMoreCommits regenerated on every render — as the previous
40280
+ // implementation did via state deps — there was a render-order
40281
+ // race: the cursor sync effect would call the PREVIOUS render's
40282
+ // callback (still in the ref because the ref-setter useEffect runs
40283
+ // after the cursor-sync effect in declaration order), which had
40284
+ // captured a stale `state.commits.length` and re-fetched the same
40285
+ // window. The auto-load chain appeared to fire but never advanced
40286
+ // through history.
40287
+ //
40288
+ // Stable identity + refs sidesteps the race entirely: the function
40289
+ // never changes, and every call reads the latest state.
40290
+ const loadMoreStateRef = React.useRef({
40291
+ commitsLength: state.commits.length,
40292
+ filteredCommitsLength: state.filteredCommits.length,
40293
+ historyFetchArgs: state.historyFetchArgs,
40294
+ hasMoreCommits,
40295
+ logArgv,
40296
+ });
40297
+ loadMoreStateRef.current = {
40298
+ commitsLength: state.commits.length,
40299
+ filteredCommitsLength: state.filteredCommits.length,
40300
+ historyFetchArgs: state.historyFetchArgs,
40301
+ hasMoreCommits,
40302
+ logArgv,
40303
+ };
40304
+ const loadMoreCommits = React.useCallback(async (options = {}) => {
40305
+ const snap = loadMoreStateRef.current;
40306
+ if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
40307
+ return { fired: false, addedCommits: 0 };
40308
+ }
40309
+ if (snap.filteredCommitsLength === 0) {
40310
+ return { fired: false, addedCommits: 0 };
40311
+ }
40312
+ loadingMoreCommitsRef.current = true;
40313
+ const requestId = loadMoreRequestRef.current + 1;
40314
+ loadMoreRequestRef.current = requestId;
40315
+ setLoadingMoreCommits(true);
40316
+ dispatch({
40317
+ type: 'setStatus',
40318
+ value: options.statusMessage || 'loading older commits',
40319
+ loading: true,
40320
+ });
40321
+ const fetchArgs = snap.historyFetchArgs;
40322
+ const mergedArgv = {
40323
+ ...snap.logArgv,
40324
+ ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
40325
+ ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
40326
+ };
40327
+ // Load-more paths a fresh page from git AFTER what's already
40328
+ // loaded; pass the stash hashes again so the additional rows
40329
+ // stay graph-consistent with the boot fetch (a window that
40330
+ // dropped stashes mid-stream would render with broken junctions).
40331
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40332
+ const nextRows = await safe(getLogRows(git, mergedArgv, {
40333
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40334
+ skip: snap.commitsLength,
40335
+ extraRefs: stashHashes,
40336
+ }));
40337
+ if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
40338
+ return { fired: false, addedCommits: 0 };
40339
+ }
40340
+ loadingMoreCommitsRef.current = false;
40341
+ setLoadingMoreCommits(false);
40342
+ const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
40343
+ if (!nextRows) {
40344
+ dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
40345
+ return { fired: false, addedCommits: 0 };
40346
+ }
40347
+ if (nextRows?.length) {
40348
+ dispatch({ type: 'appendRows', rows: nextRows });
40349
+ }
40350
+ setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40351
+ return { fired: true, addedCommits: nextCommitCount };
40352
+ // Empty deps — the function is intentionally stable. State is
40353
+ // read via `loadMoreStateRef.current` at call time, and `dispatch`
40354
+ // / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
40355
+ // already stable across renders by React's contract.
40356
+ }, [dispatch, git]);
40357
+ // Scroll-near-bottom auto-trigger. Fires when the user's cursor is
40358
+ // within 20 rows of the last loaded commit so older history is
40359
+ // already on its way by the time they reach the bottom.
39215
40360
  React.useEffect(() => {
39216
40361
  const remaining = state.filteredCommits.length - state.selectedIndex - 1;
39217
- async function loadMoreCommits() {
39218
- if (!logArgv || logArgv.limit || loadingMoreCommitsRef.current || !hasMoreCommits) {
39219
- return;
39220
- }
39221
- if (state.filteredCommits.length === 0 || remaining > 20) {
39222
- return;
39223
- }
39224
- loadingMoreCommitsRef.current = true;
39225
- const requestId = loadMoreRequestRef.current + 1;
39226
- loadMoreRequestRef.current = requestId;
39227
- setLoadingMoreCommits(true);
39228
- dispatch({ type: 'setStatus', value: 'loading older commits' });
39229
- const fetchArgs = state.historyFetchArgs;
39230
- const mergedArgv = {
39231
- ...logArgv,
39232
- ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
39233
- ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
39234
- };
39235
- const nextRows = await safe(getLogRows(git, mergedArgv, {
39236
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
39237
- skip: state.commits.length,
39238
- }));
39239
- if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
39240
- return;
39241
- }
39242
- loadingMoreCommitsRef.current = false;
39243
- setLoadingMoreCommits(false);
39244
- const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
39245
- if (!nextRows) {
39246
- dispatch({ type: 'setStatus', value: 'failed to load older commits' });
39247
- return;
39248
- }
39249
- if (nextRows?.length) {
39250
- dispatch({ type: 'appendRows', rows: nextRows });
40362
+ if (remaining > 20)
40363
+ return;
40364
+ void loadMoreCommits().then((result) => {
40365
+ if (result.fired) {
40366
+ dispatch({
40367
+ type: 'setStatus',
40368
+ value: result.addedCommits
40369
+ ? `loaded ${result.addedCommits} older commits`
40370
+ : 'end of history',
40371
+ });
39251
40372
  }
39252
- setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
39253
- dispatch({
39254
- type: 'setStatus',
39255
- value: nextCommitCount
39256
- ? `loaded ${nextCommitCount} older commits`
39257
- : 'end of history',
39258
- });
39259
- }
39260
- void loadMoreCommits();
40373
+ });
39261
40374
  }, [
39262
40375
  dispatch,
39263
- git,
39264
- hasMoreCommits,
39265
- loadingMoreCommits,
39266
- logArgv,
39267
- state.commits.length,
40376
+ loadMoreCommits,
39268
40377
  state.filteredCommits.length,
39269
- state.historyFetchArgs,
39270
40378
  state.selectedIndex,
39271
40379
  ]);
40380
+ /**
40381
+ * Targeted-context loader for the cursor-syncs-history effect. Called
40382
+ * when the resolver returns `load-context` — the user cursored a
40383
+ * branch / tag / stash whose target commit isn't in the loaded
40384
+ * window, so we run a `git log` anchored on that commit (guaranteed
40385
+ * to include it) and merge the result via `appendRows` (which
40386
+ * already deduplicates by hash).
40387
+ *
40388
+ * Stable identity (empty deps) for the same reason as
40389
+ * `loadMoreCommits` — the cursor-sync effect calls this through a
40390
+ * forward-reference ref, and a regenerating callback would
40391
+ * reintroduce the render-order race that bit the previous chain.
40392
+ * All volatile state (logArgv, mostly) is read via refs.
40393
+ */
40394
+ const loadCommitContextStateRef = React.useRef({ logArgv });
40395
+ loadCommitContextStateRef.current = { logArgv };
40396
+ const loadCommitContext = React.useCallback(async (target) => {
40397
+ const snap = loadCommitContextStateRef.current;
40398
+ if (!snap.logArgv)
40399
+ return;
40400
+ dispatch({
40401
+ type: 'setStatus',
40402
+ value: `Loading commits around ${target.label}…`,
40403
+ loading: true,
40404
+ });
40405
+ try {
40406
+ // No stashHashes here — `getLogRowsAnchoredOn` walks only from
40407
+ // the target so it can guarantee the target's inclusion.
40408
+ // Stashes are already in the loaded graph from boot's
40409
+ // `loadRowsWithStashes`; `appendRows` deduplicates by hash so
40410
+ // the merged result keeps both views without double-counting.
40411
+ const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
40412
+ if (!mountedRef.current)
40413
+ return;
40414
+ if (rows.length > 0) {
40415
+ dispatch({ type: 'appendRows', rows });
40416
+ // Don't dispatch a setStatus here — the cursor-sync effect
40417
+ // will re-fire on the appendRows-driven filteredCommits
40418
+ // change and either jump (success) or report unreachable
40419
+ // (failure), surfacing the right message.
40420
+ }
40421
+ else {
40422
+ dispatch({
40423
+ type: 'setStatus',
40424
+ value: `${target.label} target commit returned no rows — orphan ref?`,
40425
+ kind: 'warning',
40426
+ });
40427
+ }
40428
+ }
40429
+ catch (error) {
40430
+ if (mountedRef.current) {
40431
+ dispatch({
40432
+ type: 'setStatus',
40433
+ value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
40434
+ kind: 'error',
40435
+ });
40436
+ }
40437
+ }
40438
+ }, [dispatch, git]);
40439
+ React.useEffect(() => {
40440
+ loadCommitContextRef.current = loadCommitContext;
40441
+ }, [loadCommitContext]);
39272
40442
  // Server-side history filter (#776). When the user submits `path:foo`
39273
40443
  // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
39274
40444
  // this effect picks up the change, re-runs `getLogRows` with merged
@@ -39304,12 +40474,16 @@ function LogInkApp(deps) {
39304
40474
  value: description ? `Refetching with ${description}` : 'Restoring full log',
39305
40475
  });
39306
40476
  void (async () => {
39307
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40477
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40478
+ const nextRows = await safe(getLogRows(git, merged, {
40479
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40480
+ extraRefs: stashHashes,
40481
+ }));
39308
40482
  if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
39309
40483
  return;
39310
40484
  }
39311
40485
  if (!nextRows) {
39312
- dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
40486
+ dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
39313
40487
  return;
39314
40488
  }
39315
40489
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39320,6 +40494,7 @@ function LogInkApp(deps) {
39320
40494
  value: description
39321
40495
  ? `Showing ${matched} commits matching ${description}`
39322
40496
  : 'Showing full log',
40497
+ kind: 'success',
39323
40498
  });
39324
40499
  })();
39325
40500
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
@@ -39349,12 +40524,20 @@ function LogInkApp(deps) {
39349
40524
  : 'Loading compact history…',
39350
40525
  });
39351
40526
  void (async () => {
39352
- const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
40527
+ // Include stash commits as graph roots so the toggle's re-fetch
40528
+ // sees the same rich graph the boot loader assembles. Without
40529
+ // this, flipping `\` into full mode and back loses the stash
40530
+ // anchors that loadRowsWithStashes seeded on boot.
40531
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
40532
+ const nextRows = await safe(getLogRows(git, merged, {
40533
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40534
+ extraRefs: stashHashes,
40535
+ }));
39353
40536
  if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
39354
40537
  return;
39355
40538
  }
39356
40539
  if (!nextRows) {
39357
- dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
40540
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
39358
40541
  return;
39359
40542
  }
39360
40543
  dispatch({ type: 'replaceRows', rows: nextRows });
@@ -39365,6 +40548,7 @@ function LogInkApp(deps) {
39365
40548
  value: state.fullGraph
39366
40549
  ? `Showing ${matched} commits across all branches`
39367
40550
  : `Showing ${matched} commits (compact)`,
40551
+ kind: 'success',
39368
40552
  });
39369
40553
  })();
39370
40554
  }, [dispatch, git, logArgv, state.fullGraph]);
@@ -40059,6 +41243,17 @@ function createLogArgvFromUiArgv(argv) {
40059
41243
  return {
40060
41244
  $0: argv.$0,
40061
41245
  _: ['log'],
41246
+ // Pass `--all` through from the CLI. The yargs default is `true`
41247
+ // since 0.54.x — user feedback consistently asked for the
41248
+ // GitKraken-style "see all branches, tags, stashes" view as the
41249
+ // starting state. `coco ui --no-all` opts back to
41250
+ // current-branch-only.
41251
+ //
41252
+ // Note: passing `--branch foo` does NOT automatically scope away
41253
+ // from --all. If the user wants strictly that branch, they pass
41254
+ // `coco ui --branch foo --no-all`. We considered the implicit
41255
+ // scope-narrowing but it surprises users who pass `--branch` as
41256
+ // a "highlight this branch in the all-refs view" hint.
40062
41257
  all: argv.all,
40063
41258
  branch: argv.branch,
40064
41259
  format: 'table',
@@ -40095,6 +41290,26 @@ function withCacheWrite(repoPath, loader) {
40095
41290
  return rows;
40096
41291
  };
40097
41292
  }
41293
+ /**
41294
+ * Workstation-aware log loader (#1034 follow-up). Calls `git stash
41295
+ * list` first to collect every stash's commit hash, then passes them
41296
+ * as extra refs to `getLogRows` so the graph includes every stash as
41297
+ * a node — not just the latest (which is the only one `refs/stash`
41298
+ * points at and the only one `git log --all` walks).
41299
+ *
41300
+ * Without this, the stash → history cursor sync added in #1034 only
41301
+ * worked for `stash@{0}`; cursoring any older stash row reported
41302
+ * "tip not in loaded window" because that stash's commit hash was
41303
+ * never in the loaded graph window in the first place.
41304
+ *
41305
+ * The extra git call is cheap (one `git stash list --format=%H`,
41306
+ * usually sub-50ms). It's only an additive cost when stashes exist;
41307
+ * users on stash-free repos pay nothing.
41308
+ */
41309
+ async function loadRowsWithStashes(git, logArgv) {
41310
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
41311
+ return getLogRows(git, logArgv, { extraRefs: stashHashes });
41312
+ }
40098
41313
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
40099
41314
  const config = options.config || loadConfig(logArgv);
40100
41315
  const git = options.git || getRepo();
@@ -40113,7 +41328,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
40113
41328
  const initialRows = options.rows || cachedRows || [];
40114
41329
  const loadRows = options.rows
40115
41330
  ? undefined
40116
- : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
41331
+ : withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
40117
41332
  await startInkInteractiveLog(git, initialRows, {}, {
40118
41333
  appLabel: 'coco',
40119
41334
  idleTips: config.logTui?.idleTips,
@@ -40141,7 +41356,7 @@ async function startCocoUi(argv) {
40141
41356
  idleTips: config.logTui?.idleTips,
40142
41357
  dateBucketing: config.logTui?.dateBucketing,
40143
41358
  initialView: argv.view || 'history',
40144
- loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
41359
+ loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
40145
41360
  logArgv,
40146
41361
  theme: createUiTheme(config, argv),
40147
41362
  });
@@ -41344,9 +42559,9 @@ const options = {
41344
42559
  default: 'history',
41345
42560
  },
41346
42561
  all: {
41347
- description: 'Load commits from all local and remote refs in history mode',
42562
+ description: 'Load commits from all local and remote refs in history mode. Defaults to true so the history view shows the full multi-ref graph (branches, tags, stashes) out of the box; pass `--no-all` to scope to the current branch only.',
41348
42563
  type: 'boolean',
41349
- default: false,
42564
+ default: true,
41350
42565
  },
41351
42566
  branch: {
41352
42567
  description: 'Load history reachable from a branch or ref',