git-coco 0.56.0 → 0.58.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.
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.56.0";
81
+ const BUILD_VERSION = "0.58.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -1177,10 +1177,7 @@ const schema$1 = {
1177
1177
  "items": {
1178
1178
  "type": "string"
1179
1179
  },
1180
- "description": "Directories to scan for git repositories. Each entry may use a `~` prefix; resolved against the user's home directory. Defaults to `['~/code']` when omitted.",
1181
- "default": [
1182
- "~/code"
1183
- ]
1180
+ "description": "Directories to scan for git repositories. Each entry may use a `~` prefix; resolved against the user's home directory. When omitted (and no `--root` flag is passed), the workspace scans the current working directory — so a bare `coco` / `coco ws` discovers repos wherever you launched it. Set this to pin a fixed tree (e.g. `[\"~/code\"]`) regardless of where you run from.\n\n(No static `@default` — the effective default is the runtime cwd.)"
1184
1181
  },
1185
1182
  "knownRepos": {
1186
1183
  "type": "array",
@@ -2156,6 +2153,10 @@ const schema$1 = {
2156
2153
  "selection": {
2157
2154
  "type": "string"
2158
2155
  },
2156
+ "selectionForeground": {
2157
+ "type": "string",
2158
+ "description": "Foreground for text sitting on the `selection` background. Derived automatically from `selection` (black on light, white on dark) so the selected row stays readable regardless of the user's terminal default foreground — but can be overridden per theme via `options.colors`."
2159
+ },
2159
2160
  "success": {
2160
2161
  "type": "string"
2161
2162
  },
@@ -2199,7 +2200,25 @@ const schema$1 = {
2199
2200
  "vitesse-dark",
2200
2201
  "vesper",
2201
2202
  "flexoki",
2202
- "mellow"
2203
+ "mellow",
2204
+ "night-owl",
2205
+ "cobalt2",
2206
+ "oceanic-next",
2207
+ "catppuccin-macchiato",
2208
+ "gruvbox-light",
2209
+ "tokyo-night-day",
2210
+ "one-light",
2211
+ "ayu-light",
2212
+ "rose-pine-dawn",
2213
+ "everforest-light",
2214
+ "vitesse-light",
2215
+ "dayfox",
2216
+ "night-owl-light",
2217
+ "flexoki-light",
2218
+ "material-lighter",
2219
+ "papercolor-light",
2220
+ "modus-operandi",
2221
+ "quiet-light"
2203
2222
  ]
2204
2223
  }
2205
2224
  }
@@ -15229,10 +15248,19 @@ const CommitSplitPlanSchema = objectType({
15229
15248
  title: stringType().min(1),
15230
15249
  body: stringType().optional(),
15231
15250
  rationale: stringType().optional(),
15232
- files: arrayType(stringType()),
15233
- hunks: arrayType(stringType()),
15251
+ // Both optional: the model legitimately emits a group with *either*
15252
+ // `files` or `hunks` (a file-level vs hunk-level grouping), not always
15253
+ // both. Requiring both made Zod throw "Required" and the whole split
15254
+ // chain failed to parse before the refine could run. The refine below
15255
+ // still enforces "at least one", and every downstream consumer already
15256
+ // reads these as `group.files || []`. (Kept `.optional()` rather than
15257
+ // `.default([])` so the schema's input and output types stay identical
15258
+ // — `executeChainWithSchema` takes a `z.ZodSchema<T>`, which requires
15259
+ // that.)
15260
+ files: arrayType(stringType()).optional(),
15261
+ hunks: arrayType(stringType()).optional(),
15234
15262
  })
15235
- .refine((group) => group.files.length > 0 || group.hunks.length > 0, {
15263
+ .refine((group) => (group.files?.length ?? 0) > 0 || (group.hunks?.length ?? 0) > 0, {
15236
15264
  message: 'Each group must include at least one file or hunk',
15237
15265
  }))
15238
15266
  .min(1),
@@ -22830,6 +22858,13 @@ const LOG_INK_KEY_BINDINGS = [
22830
22858
  description: 'Create a lightweight tag at the cursored commit.',
22831
22859
  contexts: ['history'],
22832
22860
  },
22861
+ {
22862
+ id: 'themePicker',
22863
+ keys: ['gC'],
22864
+ label: 'theme picker',
22865
+ description: 'Browse, live-preview, and apply a color theme.',
22866
+ contexts: ['normal'],
22867
+ },
22833
22868
  {
22834
22869
  id: 'viewChangelog',
22835
22870
  keys: ['L'],
@@ -22919,6 +22954,7 @@ const BINDING_CATEGORY_BY_ID = {
22919
22954
  // them above everything else.
22920
22955
  help: 'essentials',
22921
22956
  commandPalette: 'essentials',
22957
+ themePicker: 'view',
22922
22958
  quit: 'essentials',
22923
22959
  refresh: 'essentials',
22924
22960
  navigateBack: 'essentials',
@@ -23554,635 +23590,1496 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
23554
23590
  }
23555
23591
 
23556
23592
  /**
23557
- * Canned filter presets for the issue / PR triage TUI views
23558
- * (#882 phase 6). Each preset compiles to the same shape the
23559
- * underlying list fetchers (`getIssueList` / `getPullRequestList`)
23560
- * already accept — there's no new `gh` surface area, just a
23561
- * curated set of common triage angles surfaced as a single
23562
- * keystroke (`f` cycles).
23593
+ * Explicit color-level detection for the Ink TUI (P5.2).
23563
23594
  *
23564
- * The presets are deliberately *not* a 1:1 mirror across the two
23565
- * surfaces:
23595
+ * Chalk already approximates hex colors when the terminal can't render
23596
+ * truecolor — but we want an explicit signal so the catppuccin / gruvbox
23597
+ * presets (which use hex) can fall back to the ANSI-named `default` preset
23598
+ * cleanly on minimal SSH sessions, instead of relying on chalk's
23599
+ * heuristics. Users who set `NO_COLOR` or pick the `monochrome` preset
23600
+ * still get the manual override.
23566
23601
  *
23567
- * - Issues have no draft / mergeable concept, so `draft` /
23568
- * `mergeable` are skipped on the issue list.
23569
- * - PRs have a `merged` state distinct from `closed`; issues
23570
- * don't.
23571
- * - `mine` semantics differ subtly: for issues it tends to
23572
- * mean "I'm the assignee" (issues are tasks people pick up);
23573
- * for PRs it means "I'm the author" (PRs are work people
23574
- * post). The presets bake those in so the user doesn't have
23575
- * to think about it.
23576
- */
23577
- /** Cycle order — must match the keystroke walk on `f`. */
23578
- const ISSUE_FILTER_PRESETS = [
23579
- 'open',
23580
- 'closed',
23581
- 'mine',
23582
- 'assigned',
23583
- ];
23584
- const PULL_REQUEST_FILTER_PRESETS = [
23585
- 'open',
23586
- 'draft',
23587
- 'mine',
23588
- 'assigned',
23589
- 'closed',
23590
- 'merged',
23591
- ];
23592
- const ISSUE_FILTER_LABELS = {
23593
- open: 'open',
23594
- closed: 'closed',
23595
- mine: 'mine (assigned)',
23596
- assigned: 'assigned to me',
23597
- };
23598
- const PULL_REQUEST_FILTER_LABELS = {
23599
- open: 'open',
23600
- draft: 'draft',
23601
- mine: 'mine (authored)',
23602
- assigned: 'assigned to me',
23603
- closed: 'closed',
23604
- merged: 'merged',
23605
- };
23606
- /**
23607
- * Resolve a preset to the filter object the data fetcher accepts.
23608
- * Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
23609
- * `getPullRequestList` so unit tests can assert the mapping
23610
- * independently from the fetch pipeline.
23602
+ * Levels (matching the chalk taxonomy):
23603
+ * - 'mono' → no ANSI escapes at all (NO_COLOR / TERM=dumb)
23604
+ * - '16' → standard 16-color ANSI palette
23605
+ * - '256' → xterm-256color
23606
+ * - 'truecolor' 24-bit RGB (COLORTERM=truecolor or known terminals)
23611
23607
  */
23612
- function issueFilterForPreset(preset) {
23613
- switch (preset) {
23614
- case 'open':
23615
- return { state: 'open' };
23616
- case 'closed':
23617
- return { state: 'closed' };
23618
- case 'mine':
23619
- // Issues are tasks — "mine" is what *I'm working on*, i.e.
23620
- // assigned to me + still open. Same as `assigned` plus the
23621
- // open-state filter for ergonomic single-keystroke focus on
23622
- // the active backlog.
23623
- return { state: 'open', assignee: '@me' };
23624
- case 'assigned':
23625
- return { assignee: '@me' };
23608
+ function getColorLevel(env = process.env) {
23609
+ if (env.NO_COLOR)
23610
+ return 'mono';
23611
+ switch (env.FORCE_COLOR) {
23612
+ case '0':
23613
+ return 'mono';
23614
+ case '1':
23615
+ return '16';
23616
+ case '2':
23617
+ return '256';
23618
+ case '3':
23619
+ return 'truecolor';
23626
23620
  }
23627
- }
23628
- function pullRequestFilterForPreset(preset) {
23629
- switch (preset) {
23630
- case 'open':
23631
- return { state: 'open' };
23632
- case 'draft':
23633
- // gh's `--draft` flag implies `--state open`; surface that
23634
- // explicitly so the canonicalize step doesn't elide it.
23635
- return { state: 'open', draft: true };
23636
- case 'mine':
23637
- // PRs are work — "mine" is what *I authored*. Most useful
23638
- // when looking at one's own backlog of in-flight PRs.
23639
- return { state: 'open', author: '@me' };
23640
- case 'assigned':
23641
- return { assignee: '@me' };
23642
- case 'closed':
23643
- return { state: 'closed' };
23644
- case 'merged':
23645
- return { state: 'merged' };
23621
+ const colorterm = env.COLORTERM?.toLowerCase();
23622
+ if (colorterm === 'truecolor' || colorterm === '24bit') {
23623
+ return 'truecolor';
23646
23624
  }
23647
- }
23648
- function cycleIssueFilterPreset(current) {
23649
- const index = ISSUE_FILTER_PRESETS.indexOf(current);
23650
- const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
23651
- return ISSUE_FILTER_PRESETS[next];
23652
- }
23653
- function cyclePullRequestFilterPreset(current) {
23654
- const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
23655
- const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
23656
- return PULL_REQUEST_FILTER_PRESETS[next];
23657
- }
23658
-
23659
- /**
23660
- * Sort modes for the promoted views (P4.2).
23661
- *
23662
- * Pure: takes existing context entries + the active mode, returns a sorted
23663
- * copy. Tested in isolation; the runtime just calls these helpers.
23664
- *
23665
- * Display label uses `▼` (U+25BC) under truecolor / UTF-8 and falls back to
23666
- * `v` under ASCII. Letters (`recent` / `name` / `ahead`) carry meaning;
23667
- * shape enhances.
23668
- */
23669
- const BRANCH_SORT_MODES = ['name', 'recent', 'ahead'];
23670
- const DEFAULT_BRANCH_SORT_MODE = 'name';
23671
- function cycleBranchSort(mode) {
23672
- const index = BRANCH_SORT_MODES.indexOf(mode);
23673
- if (index < 0)
23674
- return BRANCH_SORT_MODES[0];
23675
- return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
23676
- }
23677
- function sortBranches(branches, mode) {
23678
- // Pin the current branch at index 0 regardless of sort mode (#806
23679
- // follow-up). Lands the user's cursor on the active branch by
23680
- // default and keeps the most-relevant row glued to the top of the
23681
- // list as they cycle sorts.
23682
- const current = branches.find((entry) => entry.current);
23683
- const rest = branches.filter((entry) => !entry.current);
23684
- const sortedRest = rest.slice();
23685
- switch (mode) {
23686
- case 'name':
23687
- sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
23688
- break;
23689
- case 'recent':
23690
- // ISO-shaped dates compare byte-for-byte; descending so the freshest
23691
- // branch sits at the top.
23692
- sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
23693
- a.shortName.localeCompare(b.shortName));
23694
- break;
23695
- case 'ahead':
23696
- // ahead-first; ties broken by behind, then by name. Keeps "this branch
23697
- // has unmerged work" in the user's first scroll.
23698
- sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
23699
- a.shortName.localeCompare(b.shortName));
23700
- break;
23625
+ // Modern terminal emulators that publicly advertise truecolor support.
23626
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
23627
+ return 'truecolor';
23701
23628
  }
23702
- return current ? [current, ...sortedRest] : sortedRest;
23703
- }
23704
- const TAG_SORT_MODES = ['recent', 'name'];
23705
- const DEFAULT_TAG_SORT_MODE = 'recent';
23706
- function cycleTagSort(mode) {
23707
- const index = TAG_SORT_MODES.indexOf(mode);
23708
- if (index < 0)
23709
- return TAG_SORT_MODES[0];
23710
- return TAG_SORT_MODES[(index + 1) % TAG_SORT_MODES.length];
23711
- }
23712
- function sortTags(tags, mode) {
23713
- const copy = tags.slice();
23714
- switch (mode) {
23715
- case 'name':
23716
- return copy.sort((a, b) => a.name.localeCompare(b.name));
23717
- case 'recent':
23718
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
23719
- a.name.localeCompare(b.name));
23720
- default:
23721
- return copy;
23629
+ if (env.WT_SESSION) {
23630
+ return 'truecolor';
23722
23631
  }
23632
+ switch (env.TERM_PROGRAM) {
23633
+ case 'iTerm.app':
23634
+ case 'WezTerm':
23635
+ case 'vscode':
23636
+ case 'ghostty':
23637
+ case 'Hyper':
23638
+ return 'truecolor';
23639
+ }
23640
+ if (env.TERM === 'dumb')
23641
+ return 'mono';
23642
+ if (env.TERM?.includes('256color'))
23643
+ return '256';
23644
+ return '16';
23723
23645
  }
23724
- /* ---------------------------- header indicator -------------------------- */
23725
- function formatSortIndicator(mode, options = {}) {
23726
- return `${options.ascii ? 'v' : '▼'} ${mode}`;
23727
- }
23728
-
23729
- const DEFAULT_CHANGELOG_VIEW_STATE = {
23730
- status: 'idle',
23731
- scrollOffset: 0,
23732
- };
23733
- const DEFAULT_LOG_INK_STATUS_FILTER_MASK = {
23734
- staged: true,
23735
- unstaged: true,
23736
- untracked: true,
23737
- };
23646
+ const TRUECOLOR_PRESETS = new Set(['catppuccin', 'gruvbox', 'dracula', 'nord', 'solarized-dark', 'tokyo-night', 'one-dark', 'rose-pine', 'kanagawa', 'everforest', 'monokai', 'synthwave', 'ayu-dark', 'palenight', 'github-dark', 'horizon', 'nightfox', 'carbonfox', 'tokyonight-storm', 'catppuccin-latte', 'solarized-light', 'github-light', 'iceberg', 'material-ocean', 'moonlight', 'poimandres', 'vitesse-dark', 'vesper', 'flexoki', 'mellow', 'night-owl', 'cobalt2', 'oceanic-next', 'catppuccin-macchiato', 'gruvbox-light', 'tokyo-night-day', 'one-light', 'ayu-light', 'rose-pine-dawn', 'everforest-light', 'vitesse-light', 'dayfox', 'night-owl-light', 'flexoki-light', 'material-lighter', 'papercolor-light', 'modus-operandi', 'quiet-light']);
23738
23647
  /**
23739
- * Detect a history server-side filter prefix (#776). Returns the parsed
23740
- * `LogInkHistoryFetchArgs` for `path:<value>` and `author:<value>`
23741
- * prefixes, or `undefined` for a plain (client-side) filter. The whole
23742
- * remainder of the string (post-prefix) becomes the value — paths and
23743
- * author names commonly contain spaces, and we don't try to parse
23744
- * shell-like syntax.
23648
+ * `true` when the named preset relies on hex colors that look best under
23649
+ * 24-bit RGB. Used by `createLogInkTheme` to decide whether to downgrade
23650
+ * to the ANSI-named `default` palette on lower-capability terminals.
23745
23651
  */
23746
- function parseLogInkHistoryFetchPrefix(filter) {
23747
- const trimmed = filter.trim();
23748
- if (trimmed.startsWith('path:')) {
23749
- const value = trimmed.slice('path:'.length).trim();
23750
- return value ? { path: value } : undefined;
23751
- }
23752
- if (trimmed.startsWith('author:')) {
23753
- const value = trimmed.slice('author:'.length).trim();
23754
- return value ? { author: value } : undefined;
23755
- }
23756
- return undefined;
23757
- }
23758
- const FOCUS_ORDER = ['sidebar', 'commits', 'detail'];
23759
- const SIDEBAR_TABS = ['status', 'branches', 'tags', 'stashes', 'worktrees'];
23760
- function searchableFields(commit) {
23761
- return [
23762
- commit.shortHash,
23763
- commit.hash,
23764
- commit.date,
23765
- commit.author,
23766
- commit.message,
23767
- ...commit.refs,
23768
- ];
23769
- }
23770
- function scoreField(field, term) {
23771
- const value = field.toLowerCase();
23772
- const normalized = term.toLowerCase();
23773
- if (!normalized) {
23774
- return 0;
23775
- }
23776
- if (value === normalized) {
23777
- return 1000;
23778
- }
23779
- if (value.startsWith(normalized)) {
23780
- return 800 - Math.min(value.length - normalized.length, 200);
23781
- }
23782
- const substringIndex = value.indexOf(normalized);
23783
- if (substringIndex >= 0) {
23784
- return 600 - Math.min(substringIndex, 200);
23785
- }
23786
- let searchIndex = 0;
23787
- let distance = 0;
23788
- for (const character of normalized) {
23789
- const nextIndex = value.indexOf(character, searchIndex);
23790
- if (nextIndex < 0) {
23791
- return undefined;
23792
- }
23793
- distance += nextIndex - searchIndex;
23794
- searchIndex = nextIndex + 1;
23795
- }
23796
- return 300 - Math.min(distance, 200);
23797
- }
23798
- function scoreLogInkCommitFilter(commit, filter) {
23799
- const terms = filter.trim().split(/\s+/).filter(Boolean);
23800
- if (terms.length === 0) {
23801
- return 0;
23802
- }
23803
- const fields = searchableFields(commit);
23804
- let score = 0;
23805
- for (const term of terms) {
23806
- const bestFieldScore = fields.reduce((best, field) => {
23807
- const fieldScore = scoreField(field, term);
23808
- if (fieldScore === undefined) {
23809
- return best;
23810
- }
23811
- return best === undefined ? fieldScore : Math.max(best, fieldScore);
23812
- }, undefined);
23813
- if (bestFieldScore === undefined) {
23814
- return undefined;
23815
- }
23816
- score += bestFieldScore;
23817
- }
23818
- return score;
23819
- }
23820
- function filterCommits(commits, filter) {
23821
- return commits
23822
- .map((commit, index) => ({
23823
- commit,
23824
- index,
23825
- score: scoreLogInkCommitFilter(commit, filter),
23826
- }))
23827
- .filter((entry) => entry.score !== undefined)
23828
- .sort((a, b) => b.score - a.score || a.index - b.index)
23829
- .map((entry) => entry.commit);
23830
- }
23831
- function clampIndex(index, length) {
23832
- if (length === 0) {
23833
- return 0;
23834
- }
23835
- return Math.max(0, Math.min(index, length - 1));
23836
- }
23837
- function cycleValue(values, current, delta) {
23838
- const currentIndex = Math.max(0, values.indexOf(current));
23839
- const nextIndex = (currentIndex + delta + values.length) % values.length;
23840
- return values[nextIndex];
23841
- }
23842
- const HOME_VIEW = 'history';
23843
- function topOfStack(stack) {
23844
- return stack[stack.length - 1];
23845
- }
23846
- function withPushedView(state, value) {
23847
- if (topOfStack(state.viewStack) === value) {
23848
- return { ...state, pendingKey: undefined };
23849
- }
23850
- const viewStack = [...state.viewStack, value];
23851
- return {
23852
- ...state,
23853
- activeView: value,
23854
- viewStack,
23855
- // The compose + status views' right detail panels already show
23856
- // worktree info, so keeping the left sidebar on the Status tab
23857
- // duplicates that information. Auto-switch to Branches when entering
23858
- // either view; the user can swap back with [/] if they want.
23859
- //
23860
- // We update only the rendered `sidebarTab` here, never
23861
- // `userSidebarTab`, so this auto-switch is invisible to per-repo
23862
- // persistence and pop-view restores the previous tab.
23863
- sidebarTab: value === 'compose' || value === 'status' ? 'branches' : state.sidebarTab,
23864
- worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
23865
- selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
23866
- diffSource: value === 'diff' ? state.diffSource : undefined,
23867
- stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
23868
- compareHead: value === 'diff' ? state.compareHead : undefined,
23869
- pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
23870
- statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
23871
- pendingKey: undefined,
23872
- };
23873
- }
23874
- function withPoppedView(state) {
23875
- if (state.viewStack.length <= 1) {
23876
- return { ...state, pendingKey: undefined };
23877
- }
23878
- const viewStack = state.viewStack.slice(0, -1);
23879
- const next = topOfStack(viewStack);
23880
- // #779 — compareBase is "cleared when the diff view is popped." We
23881
- // detect that case by checking if the *previous* top was 'diff'.
23882
- // The compare workflow ends when the user backs out of the compare
23883
- // diff; on the next mark they re-set the base. Other view pops
23884
- // preserve compareBase so the user can move between branches / tags /
23885
- // history while hunting for a head ref.
23886
- const wasOnDiff = state.activeView === 'diff';
23887
- return {
23888
- ...state,
23889
- activeView: next,
23890
- viewStack,
23891
- // Restore the user's last explicit tab choice so popping out of
23892
- // compose / status (which auto-switch the sidebar to Branches)
23893
- // returns the user to whatever they actually had open before.
23894
- sidebarTab: state.userSidebarTab,
23895
- worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
23896
- selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
23897
- diffSource: next === 'diff' ? state.diffSource : undefined,
23898
- stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
23899
- compareBase: wasOnDiff ? undefined : state.compareBase,
23900
- compareHead: next === 'diff' ? state.compareHead : undefined,
23901
- pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
23902
- statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
23903
- pendingKey: undefined,
23904
- };
23652
+ function presetUsesTrueColor(preset) {
23653
+ return preset !== undefined && TRUECOLOR_PRESETS.has(preset);
23905
23654
  }
23906
23655
  /**
23907
- * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
23908
- * the active view position into the new frame's `parentReturn` so a
23909
- * subsequent pop lands the user back where they came from, then
23910
- * resets the per-frame navigation state (active view, view stack,
23911
- * row / file / submodule cursors, filter) so the nested frame opens
23912
- * in a clean slate — the mental equivalent of a fresh `coco ui`
23913
- * launched against the submodule's working dir.
23914
- *
23915
- * Sidebar tab + branch / tag sort are also captured into the return
23916
- * snapshot (#995) so popping back restores the parent's choices
23917
- * instead of letting the submodule's tab/sort bleed across the
23918
- * boundary. The values on the *new* frame are left as-is (carried
23919
- * over from the parent) — the load effect in app.ts re-reads
23920
- * persistence keyed on the submodule's workdir and dispatches a
23921
- * restore if the user has a submodule-specific saved preference.
23922
- *
23923
- * Other preferences (palette recents, inspector tab, diff view mode)
23924
- * stay global by design — the user's preference shouldn't reset when
23925
- * they cross a submodule boundary.
23926
- *
23927
- * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
23928
- * outside the reducer in `app.ts`'s parallel ref structure — this
23929
- * helper only manages the pure view-model side of the push.
23656
+ * WCAG 2.x relative luminance of a `#rrggbb` color, 0 (black) … 1 (white).
23657
+ * Returns `null` for anything that isn't a 6-digit hex (e.g. ANSI-named
23658
+ * colors), so callers can fall back rather than guess.
23930
23659
  */
23931
- function withPushedRepoFrame(state, payload) {
23932
- const newFrame = {
23933
- label: payload.label,
23934
- workdir: payload.workdir,
23935
- entryRange: payload.entryRange,
23936
- parentReturn: {
23937
- activeView: state.activeView,
23938
- selectedIndex: state.selectedIndex,
23939
- selectedFileIndex: state.selectedFileIndex,
23940
- selectedSubmoduleIndex: state.selectedSubmoduleIndex,
23941
- filter: state.filter,
23942
- sidebarTab: state.sidebarTab,
23943
- userSidebarTab: state.userSidebarTab,
23944
- branchSort: state.branchSort,
23945
- tagSort: state.tagSort,
23946
- },
23947
- };
23948
- return {
23949
- ...state,
23950
- repoStack: [...state.repoStack, newFrame],
23951
- activeView: 'history',
23952
- viewStack: ['history'],
23953
- selectedIndex: 0,
23954
- selectedFileIndex: 0,
23955
- selectedSubmoduleIndex: 0,
23956
- filter: '',
23957
- filterMode: false,
23958
- pendingCommitFocused: false,
23959
- pendingKey: undefined,
23960
- pendingConfirmationId: undefined,
23961
- pendingConfirmationPayload: undefined,
23962
- pendingMutationConfirmation: undefined,
23660
+ function relativeLuminance(hex) {
23661
+ const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
23662
+ if (!match)
23663
+ return null;
23664
+ const int = parseInt(match[1], 16);
23665
+ const channel = (c) => {
23666
+ const x = c / 255;
23667
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
23963
23668
  };
23669
+ const r = channel((int >> 16) & 0xff);
23670
+ const g = channel((int >> 8) & 0xff);
23671
+ const b = channel(int & 0xff);
23672
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23964
23673
  }
23965
23674
  /**
23966
- * Pop the top repo frame off `state.repoStack` (#931) and restore
23967
- * the parent's view position from the captured `parentReturn`. A
23968
- * no-op when the stack is already at its single root frame so this
23969
- * action is safe to dispatch from generic input handlers (e.g. the
23970
- * Esc auto-pop wiring that lands in a follow-up PR).
23675
+ * Pick a foreground guaranteed to stay readable on `bg` black for light
23676
+ * backgrounds, white for dark ones. The 0.179 threshold is the luminance
23677
+ * crossover where black and white yield identical contrast, so the choice
23678
+ * always maximizes it; every background clears WCAG AA (≥ 4.5:1).
23971
23679
  *
23972
- * The defensive `parentReturn` fallback handles the never-supposed-
23973
- * to-happen case where a non-root frame somehow has no return state
23974
- * recorded drop the frame but leave the user's view position
23975
- * alone rather than crash mid-session.
23680
+ * This is how the selected-row text stays legible across every theme:
23681
+ * coco controls the selection *background* but not the user's terminal
23682
+ * default foreground, so it must supply its own contrasting foreground
23683
+ * instead of hoping the terminal's happens to contrast. Returns
23684
+ * `undefined` for non-hex backgrounds (let the caller leave color alone).
23976
23685
  */
23977
- function withPoppedRepoFrame(state) {
23978
- if (state.repoStack.length <= 1) {
23979
- return { ...state, pendingKey: undefined };
23980
- }
23981
- const topFrame = state.repoStack[state.repoStack.length - 1];
23982
- const ret = topFrame.parentReturn;
23983
- const repoStack = state.repoStack.slice(0, -1);
23984
- if (!ret) {
23985
- return { ...state, repoStack, pendingKey: undefined };
23986
- }
23987
- return {
23988
- ...state,
23989
- repoStack,
23990
- activeView: ret.activeView,
23991
- viewStack: [ret.activeView],
23992
- selectedIndex: ret.selectedIndex,
23993
- selectedFileIndex: ret.selectedFileIndex,
23994
- selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
23995
- filter: ret.filter,
23996
- filterMode: false,
23997
- pendingCommitFocused: false,
23998
- // #995 — restore sidebar tab + sort preferences from the captured
23999
- // parentReturn. Without this, the submodule's tab / sort choice
24000
- // bleeds back into the parent after pop: the user picks 'tags' in
24001
- // a vendored submodule, pops back to the parent, and finds the
24002
- // parent's previously-selected 'branches' tab quietly replaced.
24003
- sidebarTab: ret.sidebarTab,
24004
- userSidebarTab: ret.userSidebarTab,
24005
- branchSort: ret.branchSort,
24006
- tagSort: ret.tagSort,
24007
- pendingKey: undefined,
24008
- pendingConfirmationId: undefined,
24009
- pendingConfirmationPayload: undefined,
24010
- pendingMutationConfirmation: undefined,
24011
- };
23686
+ function readableForegroundFor(bg) {
23687
+ if (!bg)
23688
+ return undefined;
23689
+ const luminance = relativeLuminance(bg);
23690
+ if (luminance === null)
23691
+ return undefined;
23692
+ return luminance > 0.179 ? '#000000' : '#ffffff';
24012
23693
  }
24013
- function withReplacedView(state, value) {
24014
- if (topOfStack(state.viewStack) === value) {
24015
- return { ...state, pendingKey: undefined };
24016
- }
24017
- const viewStack = [...state.viewStack.slice(0, -1), value];
24018
- return {
24019
- ...state,
24020
- activeView: value,
24021
- viewStack,
24022
- worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
24023
- selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24024
- diffSource: value === 'diff' ? state.diffSource : undefined,
24025
- stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
24026
- compareHead: value === 'diff' ? state.compareHead : undefined,
24027
- pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24028
- statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24029
- pendingKey: undefined,
24030
- };
23694
+
23695
+ const THEME_PRESET_COLORS = {
23696
+ default: {
23697
+ accent: 'cyan',
23698
+ border: 'gray',
23699
+ danger: 'red',
23700
+ focusBorder: 'cyan',
23701
+ gitAdded: 'green',
23702
+ gitDeleted: 'red',
23703
+ gitModified: 'yellow',
23704
+ info: 'blue',
23705
+ muted: 'gray',
23706
+ selection: '#1a3a4a',
23707
+ success: 'green',
23708
+ warning: 'yellow',
23709
+ },
23710
+ catppuccin: {
23711
+ accent: '#89b4fa',
23712
+ border: '#585b70',
23713
+ danger: '#f38ba8',
23714
+ focusBorder: '#89dceb',
23715
+ gitAdded: '#a6e3a1',
23716
+ gitDeleted: '#f38ba8',
23717
+ gitModified: '#f9e2af',
23718
+ info: '#89b4fa',
23719
+ muted: '#6c7086',
23720
+ selection: '#45475a',
23721
+ success: '#a6e3a1',
23722
+ warning: '#f9e2af',
23723
+ },
23724
+ gruvbox: {
23725
+ accent: '#83a598',
23726
+ border: '#665c54',
23727
+ danger: '#fb4934',
23728
+ focusBorder: '#8ec07c',
23729
+ gitAdded: '#b8bb26',
23730
+ gitDeleted: '#fb4934',
23731
+ gitModified: '#fabd2f',
23732
+ info: '#83a598',
23733
+ muted: '#928374',
23734
+ selection: '#504945',
23735
+ success: '#b8bb26',
23736
+ warning: '#fabd2f',
23737
+ },
23738
+ dracula: {
23739
+ accent: '#bd93f9',
23740
+ border: '#44475a',
23741
+ danger: '#ff5555',
23742
+ focusBorder: '#ff79c6',
23743
+ gitAdded: '#50fa7b',
23744
+ gitDeleted: '#ff5555',
23745
+ gitModified: '#f1fa8c',
23746
+ info: '#8be9fd',
23747
+ muted: '#6272a4',
23748
+ selection: '#44475a',
23749
+ success: '#50fa7b',
23750
+ warning: '#f1fa8c',
23751
+ },
23752
+ nord: {
23753
+ accent: '#88c0d0',
23754
+ border: '#3b4252',
23755
+ danger: '#bf616a',
23756
+ focusBorder: '#81a1c1',
23757
+ gitAdded: '#a3be8c',
23758
+ gitDeleted: '#bf616a',
23759
+ gitModified: '#ebcb8b',
23760
+ info: '#81a1c1',
23761
+ muted: '#4c566a',
23762
+ selection: '#3b4252',
23763
+ success: '#a3be8c',
23764
+ warning: '#ebcb8b',
23765
+ },
23766
+ 'solarized-dark': {
23767
+ accent: '#268bd2',
23768
+ border: '#073642',
23769
+ danger: '#dc322f',
23770
+ focusBorder: '#2aa198',
23771
+ gitAdded: '#859900',
23772
+ gitDeleted: '#dc322f',
23773
+ gitModified: '#b58900',
23774
+ info: '#268bd2',
23775
+ muted: '#586e75',
23776
+ selection: '#073642',
23777
+ success: '#859900',
23778
+ warning: '#b58900',
23779
+ },
23780
+ 'tokyo-night': {
23781
+ accent: '#7aa2f7',
23782
+ border: '#3b4261',
23783
+ danger: '#f7768e',
23784
+ focusBorder: '#7dcfff',
23785
+ gitAdded: '#9ece6a',
23786
+ gitDeleted: '#f7768e',
23787
+ gitModified: '#e0af68',
23788
+ info: '#7aa2f7',
23789
+ muted: '#565f89',
23790
+ selection: '#33467c',
23791
+ success: '#9ece6a',
23792
+ warning: '#e0af68',
23793
+ },
23794
+ 'one-dark': {
23795
+ accent: '#61afef',
23796
+ border: '#3e4452',
23797
+ danger: '#e06c75',
23798
+ focusBorder: '#56b6c2',
23799
+ gitAdded: '#98c379',
23800
+ gitDeleted: '#e06c75',
23801
+ gitModified: '#e5c07b',
23802
+ info: '#61afef',
23803
+ muted: '#5c6370',
23804
+ selection: '#3e4452',
23805
+ success: '#98c379',
23806
+ warning: '#e5c07b',
23807
+ },
23808
+ 'rose-pine': {
23809
+ accent: '#c4a7e7',
23810
+ border: '#26233a',
23811
+ danger: '#eb6f92',
23812
+ focusBorder: '#9ccfd8',
23813
+ gitAdded: '#31748f',
23814
+ gitDeleted: '#eb6f92',
23815
+ gitModified: '#f6c177',
23816
+ info: '#9ccfd8',
23817
+ muted: '#6e6a86',
23818
+ selection: '#2a273f',
23819
+ success: '#31748f',
23820
+ warning: '#f6c177',
23821
+ },
23822
+ kanagawa: {
23823
+ accent: '#7e9cd8',
23824
+ border: '#2a2a37',
23825
+ danger: '#e82424',
23826
+ focusBorder: '#7fb4ca',
23827
+ gitAdded: '#76946a',
23828
+ gitDeleted: '#e82424',
23829
+ gitModified: '#dca561',
23830
+ info: '#7e9cd8',
23831
+ muted: '#727169',
23832
+ selection: '#2d4f67',
23833
+ success: '#76946a',
23834
+ warning: '#dca561',
23835
+ },
23836
+ everforest: {
23837
+ accent: '#a7c080',
23838
+ border: '#374145',
23839
+ danger: '#e67e80',
23840
+ focusBorder: '#83c092',
23841
+ gitAdded: '#a7c080',
23842
+ gitDeleted: '#e67e80',
23843
+ gitModified: '#dbbc7f',
23844
+ info: '#7fbbb3',
23845
+ muted: '#859289',
23846
+ selection: '#374145',
23847
+ success: '#a7c080',
23848
+ warning: '#dbbc7f',
23849
+ },
23850
+ monokai: {
23851
+ accent: '#66d9ef',
23852
+ border: '#49483e',
23853
+ danger: '#f92672',
23854
+ focusBorder: '#a6e22e',
23855
+ gitAdded: '#a6e22e',
23856
+ gitDeleted: '#f92672',
23857
+ gitModified: '#e6db74',
23858
+ info: '#66d9ef',
23859
+ muted: '#75715e',
23860
+ selection: '#49483e',
23861
+ success: '#a6e22e',
23862
+ warning: '#e6db74',
23863
+ },
23864
+ synthwave: {
23865
+ accent: '#f97e72',
23866
+ border: '#34294f',
23867
+ danger: '#fe4450',
23868
+ focusBorder: '#36f9f6',
23869
+ gitAdded: '#72f1b8',
23870
+ gitDeleted: '#fe4450',
23871
+ gitModified: '#fede5d',
23872
+ info: '#36f9f6',
23873
+ muted: '#848bbd',
23874
+ selection: '#34294f',
23875
+ success: '#72f1b8',
23876
+ warning: '#fede5d',
23877
+ },
23878
+ 'ayu-dark': {
23879
+ accent: '#e6b450',
23880
+ border: '#11151c',
23881
+ danger: '#f07178',
23882
+ focusBorder: '#39bae6',
23883
+ gitAdded: '#7fd962',
23884
+ gitDeleted: '#f07178',
23885
+ gitModified: '#e6b450',
23886
+ info: '#39bae6',
23887
+ muted: '#565b66',
23888
+ selection: '#1a1f29',
23889
+ success: '#7fd962',
23890
+ warning: '#e6b450',
23891
+ },
23892
+ palenight: {
23893
+ accent: '#82aaff',
23894
+ border: '#3a3f58',
23895
+ danger: '#ff5370',
23896
+ focusBorder: '#89ddff',
23897
+ gitAdded: '#c3e88d',
23898
+ gitDeleted: '#ff5370',
23899
+ gitModified: '#ffcb6b',
23900
+ info: '#82aaff',
23901
+ muted: '#676e95',
23902
+ selection: '#3a3f58',
23903
+ success: '#c3e88d',
23904
+ warning: '#ffcb6b',
23905
+ },
23906
+ 'github-dark': {
23907
+ accent: '#58a6ff',
23908
+ border: '#30363d',
23909
+ danger: '#f85149',
23910
+ focusBorder: '#58a6ff',
23911
+ gitAdded: '#3fb950',
23912
+ gitDeleted: '#f85149',
23913
+ gitModified: '#d29922',
23914
+ info: '#58a6ff',
23915
+ muted: '#8b949e',
23916
+ selection: '#264f78',
23917
+ success: '#3fb950',
23918
+ warning: '#d29922',
23919
+ },
23920
+ horizon: {
23921
+ accent: '#e95678',
23922
+ border: '#2e303e',
23923
+ danger: '#e95678',
23924
+ focusBorder: '#25b0bc',
23925
+ gitAdded: '#09f7a0',
23926
+ gitDeleted: '#e95678',
23927
+ gitModified: '#fab795',
23928
+ info: '#25b0bc',
23929
+ muted: '#6c6f93',
23930
+ selection: '#2e303e',
23931
+ success: '#09f7a0',
23932
+ warning: '#fab795',
23933
+ },
23934
+ nightfox: {
23935
+ accent: '#719cd6',
23936
+ border: '#2b3b51',
23937
+ danger: '#c94f6d',
23938
+ focusBorder: '#63cdcf',
23939
+ gitAdded: '#81b29a',
23940
+ gitDeleted: '#c94f6d',
23941
+ gitModified: '#dbc074',
23942
+ info: '#719cd6',
23943
+ muted: '#738091',
23944
+ selection: '#2b3b51',
23945
+ success: '#81b29a',
23946
+ warning: '#dbc074',
23947
+ },
23948
+ carbonfox: {
23949
+ accent: '#78a9ff',
23950
+ border: '#353535',
23951
+ danger: '#ee5396',
23952
+ focusBorder: '#33b1ff',
23953
+ gitAdded: '#42be65',
23954
+ gitDeleted: '#ee5396',
23955
+ gitModified: '#ffe97b',
23956
+ info: '#78a9ff',
23957
+ muted: '#7b7c7e',
23958
+ selection: '#353535',
23959
+ success: '#42be65',
23960
+ warning: '#ffe97b',
23961
+ },
23962
+ 'tokyonight-storm': {
23963
+ accent: '#7aa2f7',
23964
+ border: '#2f334d',
23965
+ danger: '#f7768e',
23966
+ focusBorder: '#2ac3de',
23967
+ gitAdded: '#9ece6a',
23968
+ gitDeleted: '#f7768e',
23969
+ gitModified: '#e0af68',
23970
+ info: '#2ac3de',
23971
+ muted: '#545c7e',
23972
+ selection: '#2f334d',
23973
+ success: '#9ece6a',
23974
+ warning: '#e0af68',
23975
+ },
23976
+ 'catppuccin-latte': {
23977
+ accent: '#1e66f5',
23978
+ border: '#ccd0da',
23979
+ danger: '#d20f39',
23980
+ focusBorder: '#179299',
23981
+ gitAdded: '#40a02b',
23982
+ gitDeleted: '#d20f39',
23983
+ gitModified: '#df8e1d',
23984
+ info: '#1e66f5',
23985
+ muted: '#9ca0b0',
23986
+ selection: '#ccd0da',
23987
+ success: '#40a02b',
23988
+ warning: '#df8e1d',
23989
+ },
23990
+ 'solarized-light': {
23991
+ accent: '#268bd2',
23992
+ border: '#eee8d5',
23993
+ danger: '#dc322f',
23994
+ focusBorder: '#2aa198',
23995
+ gitAdded: '#859900',
23996
+ gitDeleted: '#dc322f',
23997
+ gitModified: '#b58900',
23998
+ info: '#268bd2',
23999
+ muted: '#93a1a1',
24000
+ selection: '#eee8d5',
24001
+ success: '#859900',
24002
+ warning: '#b58900',
24003
+ },
24004
+ 'github-light': {
24005
+ accent: '#0969da',
24006
+ border: '#d0d7de',
24007
+ danger: '#cf222e',
24008
+ focusBorder: '#0969da',
24009
+ gitAdded: '#1a7f37',
24010
+ gitDeleted: '#cf222e',
24011
+ gitModified: '#9a6700',
24012
+ info: '#0969da',
24013
+ muted: '#656d76',
24014
+ selection: '#ddf4ff',
24015
+ success: '#1a7f37',
24016
+ warning: '#9a6700',
24017
+ },
24018
+ iceberg: {
24019
+ accent: '#84a0c6',
24020
+ border: '#1e2132',
24021
+ danger: '#e27878',
24022
+ focusBorder: '#89b8c2',
24023
+ gitAdded: '#b4be82',
24024
+ gitDeleted: '#e27878',
24025
+ gitModified: '#e2a478',
24026
+ info: '#84a0c6',
24027
+ muted: '#6b7089',
24028
+ selection: '#1e2132',
24029
+ success: '#b4be82',
24030
+ warning: '#e2a478',
24031
+ },
24032
+ 'material-ocean': {
24033
+ accent: '#82aaff',
24034
+ border: '#2b2f3a',
24035
+ danger: '#f07178',
24036
+ focusBorder: '#89ddff',
24037
+ gitAdded: '#c3e88d',
24038
+ gitDeleted: '#f07178',
24039
+ gitModified: '#ffcb6b',
24040
+ info: '#82aaff',
24041
+ muted: '#464b5d',
24042
+ selection: '#2b2f3a',
24043
+ success: '#c3e88d',
24044
+ warning: '#ffcb6b',
24045
+ },
24046
+ moonlight: {
24047
+ accent: '#82aaff',
24048
+ border: '#2f334d',
24049
+ danger: '#ff757f',
24050
+ focusBorder: '#86e1fc',
24051
+ gitAdded: '#c3e88d',
24052
+ gitDeleted: '#ff757f',
24053
+ gitModified: '#ffc777',
24054
+ info: '#82aaff',
24055
+ muted: '#636da6',
24056
+ selection: '#2f334d',
24057
+ success: '#c3e88d',
24058
+ warning: '#ffc777',
24059
+ },
24060
+ poimandres: {
24061
+ accent: '#add7ff',
24062
+ border: '#1b1e28',
24063
+ danger: '#d0679d',
24064
+ focusBorder: '#5de4c7',
24065
+ gitAdded: '#5de4c7',
24066
+ gitDeleted: '#d0679d',
24067
+ gitModified: '#fffac2',
24068
+ info: '#add7ff',
24069
+ muted: '#506477',
24070
+ selection: '#1b1e28',
24071
+ success: '#5de4c7',
24072
+ warning: '#fffac2',
24073
+ },
24074
+ 'vitesse-dark': {
24075
+ accent: '#4d9375',
24076
+ border: '#282828',
24077
+ danger: '#cb7676',
24078
+ focusBorder: '#4d9375',
24079
+ gitAdded: '#4d9375',
24080
+ gitDeleted: '#cb7676',
24081
+ gitModified: '#e6cc77',
24082
+ info: '#6394bf',
24083
+ muted: '#758575',
24084
+ selection: '#282828',
24085
+ success: '#4d9375',
24086
+ warning: '#e6cc77',
24087
+ },
24088
+ vesper: {
24089
+ accent: '#ffc799',
24090
+ border: '#232323',
24091
+ danger: '#f5a191',
24092
+ focusBorder: '#99ffe4',
24093
+ gitAdded: '#99ffe4',
24094
+ gitDeleted: '#f5a191',
24095
+ gitModified: '#ffc799',
24096
+ info: '#a0c4ff',
24097
+ muted: '#575757',
24098
+ selection: '#232323',
24099
+ success: '#99ffe4',
24100
+ warning: '#ffc799',
24101
+ },
24102
+ flexoki: {
24103
+ accent: '#205ea6',
24104
+ border: '#343331',
24105
+ danger: '#af3029',
24106
+ focusBorder: '#24837b',
24107
+ gitAdded: '#66800b',
24108
+ gitDeleted: '#af3029',
24109
+ gitModified: '#ad8301',
24110
+ info: '#205ea6',
24111
+ muted: '#878580',
24112
+ selection: '#343331',
24113
+ success: '#66800b',
24114
+ warning: '#ad8301',
24115
+ },
24116
+ mellow: {
24117
+ accent: '#7eb8da',
24118
+ border: '#2a2a2a',
24119
+ danger: '#f5a191',
24120
+ focusBorder: '#a3d4a0',
24121
+ gitAdded: '#a3d4a0',
24122
+ gitDeleted: '#f5a191',
24123
+ gitModified: '#f0c674',
24124
+ info: '#7eb8da',
24125
+ muted: '#6b6b6b',
24126
+ selection: '#2a2a2a',
24127
+ success: '#a3d4a0',
24128
+ warning: '#f0c674',
24129
+ },
24130
+ 'night-owl': {
24131
+ accent: '#82aaff',
24132
+ border: '#1d3b53',
24133
+ danger: '#ef5350',
24134
+ focusBorder: '#7fdbca',
24135
+ gitAdded: '#addb67',
24136
+ gitDeleted: '#ef5350',
24137
+ gitModified: '#ecc48d',
24138
+ info: '#82aaff',
24139
+ muted: '#637777',
24140
+ selection: '#1d3b53',
24141
+ success: '#addb67',
24142
+ warning: '#ecc48d',
24143
+ },
24144
+ cobalt2: {
24145
+ accent: '#ffc600',
24146
+ border: '#234e6d',
24147
+ danger: '#ff628c',
24148
+ focusBorder: '#9effff',
24149
+ gitAdded: '#3ad900',
24150
+ gitDeleted: '#ff628c',
24151
+ gitModified: '#ffc600',
24152
+ info: '#9effff',
24153
+ muted: '#627e99',
24154
+ selection: '#0d3a58',
24155
+ success: '#3ad900',
24156
+ warning: '#ffc600',
24157
+ },
24158
+ 'oceanic-next': {
24159
+ accent: '#6699cc',
24160
+ border: '#343d46',
24161
+ danger: '#ec5f67',
24162
+ focusBorder: '#5fb3b3',
24163
+ gitAdded: '#99c794',
24164
+ gitDeleted: '#ec5f67',
24165
+ gitModified: '#fac863',
24166
+ info: '#6699cc',
24167
+ muted: '#65737e',
24168
+ selection: '#4f5b66',
24169
+ success: '#99c794',
24170
+ warning: '#fac863',
24171
+ },
24172
+ 'catppuccin-macchiato': {
24173
+ accent: '#8aadf4',
24174
+ border: '#494d64',
24175
+ danger: '#ed8796',
24176
+ focusBorder: '#91d7e3',
24177
+ gitAdded: '#a6da95',
24178
+ gitDeleted: '#ed8796',
24179
+ gitModified: '#eed49f',
24180
+ info: '#8aadf4',
24181
+ muted: '#6e738d',
24182
+ selection: '#363a4f',
24183
+ success: '#a6da95',
24184
+ warning: '#eed49f',
24185
+ },
24186
+ 'gruvbox-light': {
24187
+ accent: '#076678',
24188
+ border: '#bdae93',
24189
+ danger: '#9d0006',
24190
+ focusBorder: '#427b58',
24191
+ gitAdded: '#79740e',
24192
+ gitDeleted: '#9d0006',
24193
+ gitModified: '#b57614',
24194
+ info: '#076678',
24195
+ muted: '#7c6f64',
24196
+ selection: '#ebdbb2',
24197
+ success: '#79740e',
24198
+ warning: '#b57614',
24199
+ },
24200
+ 'tokyo-night-day': {
24201
+ accent: '#2e7de9',
24202
+ border: '#b7c1e3',
24203
+ danger: '#f52a65',
24204
+ focusBorder: '#007197',
24205
+ gitAdded: '#587539',
24206
+ gitDeleted: '#f52a65',
24207
+ gitModified: '#8c6c3e',
24208
+ info: '#2e7de9',
24209
+ muted: '#848cb5',
24210
+ selection: '#b7c1e3',
24211
+ success: '#587539',
24212
+ warning: '#8c6c3e',
24213
+ },
24214
+ 'one-light': {
24215
+ accent: '#4078f2',
24216
+ border: '#d4d4d4',
24217
+ danger: '#e45649',
24218
+ focusBorder: '#0184bc',
24219
+ gitAdded: '#50a14f',
24220
+ gitDeleted: '#e45649',
24221
+ gitModified: '#c18401',
24222
+ info: '#4078f2',
24223
+ muted: '#a0a1a7',
24224
+ selection: '#e5e5e6',
24225
+ success: '#50a14f',
24226
+ warning: '#c18401',
24227
+ },
24228
+ 'ayu-light': {
24229
+ accent: '#fa8d3e',
24230
+ border: '#e6e6e6',
24231
+ danger: '#e65050',
24232
+ focusBorder: '#4cbf99',
24233
+ gitAdded: '#6cbf43',
24234
+ gitDeleted: '#e65050',
24235
+ gitModified: '#f2ae49',
24236
+ info: '#399ee6',
24237
+ muted: '#abb0b6',
24238
+ selection: '#d1e4f4',
24239
+ success: '#6cbf43',
24240
+ warning: '#f2ae49',
24241
+ },
24242
+ 'rose-pine-dawn': {
24243
+ accent: '#907aa9',
24244
+ border: '#dfdad9',
24245
+ danger: '#b4637a',
24246
+ focusBorder: '#56949f',
24247
+ gitAdded: '#286983',
24248
+ gitDeleted: '#b4637a',
24249
+ gitModified: '#ea9d34',
24250
+ info: '#56949f',
24251
+ muted: '#9893a5',
24252
+ selection: '#dfdad9',
24253
+ success: '#286983',
24254
+ warning: '#ea9d34',
24255
+ },
24256
+ 'everforest-light': {
24257
+ accent: '#8da101',
24258
+ border: '#ddd8be',
24259
+ danger: '#f85552',
24260
+ focusBorder: '#35a77c',
24261
+ gitAdded: '#8da101',
24262
+ gitDeleted: '#f85552',
24263
+ gitModified: '#dfa000',
24264
+ info: '#3a94c5',
24265
+ muted: '#939f91',
24266
+ selection: '#edeada',
24267
+ success: '#8da101',
24268
+ warning: '#dfa000',
24269
+ },
24270
+ 'vitesse-light': {
24271
+ accent: '#1e754f',
24272
+ border: '#e0e0e0',
24273
+ danger: '#ab5959',
24274
+ focusBorder: '#2993a3',
24275
+ gitAdded: '#1e754f',
24276
+ gitDeleted: '#ab5959',
24277
+ gitModified: '#b07d48',
24278
+ info: '#296aa3',
24279
+ muted: '#999fa6',
24280
+ selection: '#eaeaeb',
24281
+ success: '#1e754f',
24282
+ warning: '#b07d48',
24283
+ },
24284
+ dayfox: {
24285
+ accent: '#2848a9',
24286
+ border: '#e4dcd4',
24287
+ danger: '#a5222f',
24288
+ focusBorder: '#287980',
24289
+ gitAdded: '#396847',
24290
+ gitDeleted: '#a5222f',
24291
+ gitModified: '#ac5402',
24292
+ info: '#2848a9',
24293
+ muted: '#908479',
24294
+ selection: '#e7d2be',
24295
+ success: '#396847',
24296
+ warning: '#ac5402',
24297
+ },
24298
+ 'night-owl-light': {
24299
+ accent: '#288ed7',
24300
+ border: '#d9d9d9',
24301
+ danger: '#d3423e',
24302
+ focusBorder: '#2aa298',
24303
+ gitAdded: '#08916a',
24304
+ gitDeleted: '#d3423e',
24305
+ gitModified: '#daaa01',
24306
+ info: '#288ed7',
24307
+ muted: '#989fb1',
24308
+ selection: '#e4e8f0',
24309
+ success: '#08916a',
24310
+ warning: '#daaa01',
24311
+ },
24312
+ 'flexoki-light': {
24313
+ accent: '#205ea6',
24314
+ border: '#cecdc3',
24315
+ danger: '#af3029',
24316
+ focusBorder: '#24837b',
24317
+ gitAdded: '#66800b',
24318
+ gitDeleted: '#af3029',
24319
+ gitModified: '#ad8301',
24320
+ info: '#205ea6',
24321
+ muted: '#6f6e69',
24322
+ selection: '#e6e4d9',
24323
+ success: '#66800b',
24324
+ warning: '#ad8301',
24325
+ },
24326
+ 'material-lighter': {
24327
+ accent: '#39adb5',
24328
+ border: '#e7eaec',
24329
+ danger: '#e53935',
24330
+ focusBorder: '#39adb5',
24331
+ gitAdded: '#91b859',
24332
+ gitDeleted: '#e53935',
24333
+ gitModified: '#f6a434',
24334
+ info: '#6182b8',
24335
+ muted: '#90a4ae',
24336
+ selection: '#d3e1e8',
24337
+ success: '#91b859',
24338
+ warning: '#f6a434',
24339
+ },
24340
+ 'papercolor-light': {
24341
+ accent: '#0087af',
24342
+ border: '#d7d7d7',
24343
+ danger: '#af0000',
24344
+ focusBorder: '#005f87',
24345
+ gitAdded: '#008700',
24346
+ gitDeleted: '#af0000',
24347
+ gitModified: '#d75f00',
24348
+ info: '#0087af',
24349
+ muted: '#878787',
24350
+ selection: '#d0d0d0',
24351
+ success: '#008700',
24352
+ warning: '#d75f00',
24353
+ },
24354
+ 'modus-operandi': {
24355
+ accent: '#0031a9',
24356
+ border: '#d7d7d7',
24357
+ danger: '#a60000',
24358
+ focusBorder: '#005e8b',
24359
+ gitAdded: '#006800',
24360
+ gitDeleted: '#a60000',
24361
+ gitModified: '#6f5500',
24362
+ info: '#0031a9',
24363
+ muted: '#595959',
24364
+ selection: '#c0deff',
24365
+ success: '#006800',
24366
+ warning: '#6f5500',
24367
+ },
24368
+ 'quiet-light': {
24369
+ accent: '#4b83cd',
24370
+ border: '#e0e0e0',
24371
+ danger: '#aa3731',
24372
+ focusBorder: '#4b83cd',
24373
+ gitAdded: '#448c27',
24374
+ gitDeleted: '#aa3731',
24375
+ gitModified: '#a67d00',
24376
+ info: '#4b83cd',
24377
+ muted: '#a3a6ad',
24378
+ selection: '#c9d0d9',
24379
+ success: '#448c27',
24380
+ warning: '#a67d00',
24381
+ },
24382
+ };
24383
+ /**
24384
+ * Ordered list of every selectable theme preset, for the `coco ui` theme
24385
+ * picker and any UI that enumerates themes. `monochrome` isn't a key in
24386
+ * `THEME_PRESET_COLORS` (it's handled via `noColor`), so it's spliced in
24387
+ * right after `default` — the two non-color baselines sit together at the
24388
+ * top, followed by the color themes in catalog order.
24389
+ */
24390
+ function getLogInkThemePresets() {
24391
+ const keys = Object.keys(THEME_PRESET_COLORS);
24392
+ const [first, ...rest] = keys;
24393
+ return first === 'default'
24394
+ ? ['default', 'monochrome', ...rest]
24395
+ : ['monochrome', ...keys];
24031
24396
  }
24032
- function withFilter(state, filter, promotedSelections) {
24033
- const filteredCommits = filterCommits(state.commits, filter);
24034
- // P4.5: rectify promoted-view selections when the filter changes. Prefer
24035
- // the runtime-supplied snapshot — which preserves the cursor on the same
24036
- // item when it's still in the filtered list and only snaps to result[0]
24037
- // when the previously-selected item dropped out. Falls back to the older
24038
- // "snap to 0" behavior when no snapshot was provided (test paths,
24039
- // dispatchers without context).
24040
- const filterChanged = state.filter !== filter;
24041
- const branchIndex = promotedSelections?.branchIndex ??
24042
- (filterChanged ? 0 : state.selectedBranchIndex);
24043
- const tagIndex = promotedSelections?.tagIndex ??
24044
- (filterChanged ? 0 : state.selectedTagIndex);
24045
- const stashIndex = promotedSelections?.stashIndex ??
24046
- (filterChanged ? 0 : state.selectedStashIndex);
24047
- // Reflog (#781) snaps to 0 on filter change rather than rectifying.
24048
- // The list is chronological and the user is unlikely to be tracking
24049
- // a specific entry through filter changes — the simpler reset
24050
- // matches the "find recovery target by typing" interaction.
24051
- const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
24052
- return {
24053
- ...state,
24054
- filter,
24055
- filteredCommits,
24056
- selectedIndex: clampIndex(state.selectedIndex, filteredCommits.length),
24057
- selectedFileIndex: 0,
24058
- selectedBranchIndex: branchIndex,
24059
- selectedTagIndex: tagIndex,
24060
- selectedStashIndex: stashIndex,
24061
- selectedReflogIndex: reflogIndex,
24062
- diffPreviewOffset: 0,
24063
- pendingKey: undefined,
24064
- };
24397
+ function shouldUseAscii(term) {
24398
+ if (!term) {
24399
+ return false;
24400
+ }
24401
+ return term === 'dumb' || term.startsWith('vt100');
24065
24402
  }
24066
- function replaceRows(state, rows) {
24067
- // Wholesale row replacement after a server-side re-fetch (#776).
24068
- // Resets the cursor to the top because the new commit set may not
24069
- // share any hashes with the old one (e.g. switching from `--all` to
24070
- // `-- some/path` typically dumps the previous selection).
24071
- const commits = getCommitRows(rows);
24072
- const filteredCommits = filterCommits(commits, state.filter);
24403
+ function createLogInkTheme(options = {}) {
24404
+ const noColor = (options.noColor ?? Boolean(process.env.NO_COLOR)) ||
24405
+ options.preset === 'monochrome';
24406
+ const ascii = options.ascii ?? shouldUseAscii(options.term ?? process.env.TERM);
24407
+ const requestedPreset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
24408
+ // P5.2 gracefully downgrade hex presets (catppuccin / gruvbox) when
24409
+ // the host terminal can't render truecolor. Chalk approximates hex in
24410
+ // those modes anyway, but the default preset's ANSI-named palette
24411
+ // renders far more faithfully on 16-color terminals.
24412
+ const colorLevel = getColorLevel(options.env ?? process.env);
24413
+ const preset = !noColor && presetUsesTrueColor(requestedPreset) && colorLevel !== 'truecolor'
24414
+ ? 'default'
24415
+ : requestedPreset;
24416
+ const colors = noColor
24417
+ ? {}
24418
+ : {
24419
+ ...THEME_PRESET_COLORS[preset],
24420
+ // Preserve the requested theme's selection background even when the
24421
+ // rest of the palette downgrades to `default`. The selection is a
24422
+ // single background color the terminal can approximate; without this,
24423
+ // a light theme inherits `default`'s dark selection (#1a3a4a) and the
24424
+ // selected row renders as a dark bar on a light background.
24425
+ ...(preset !== requestedPreset && THEME_PRESET_COLORS[requestedPreset]?.selection
24426
+ ? { selection: THEME_PRESET_COLORS[requestedPreset].selection }
24427
+ : {}),
24428
+ ...options.colors,
24429
+ };
24430
+ // Derive a contrasting foreground for the selected row from its own
24431
+ // selection background, unless the caller supplied one explicitly. coco
24432
+ // owns the selection background but not the terminal's default foreground,
24433
+ // so without this the selected row's text falls back to whatever the
24434
+ // user's terminal foreground is — which may not contrast with the bar at
24435
+ // all (the bug behind unreadable selected rows on many themes).
24436
+ if (!noColor && colors.selection && !colors.selectionForeground) {
24437
+ const selectionForeground = readableForegroundFor(colors.selection);
24438
+ if (selectionForeground) {
24439
+ colors.selectionForeground = selectionForeground;
24440
+ }
24441
+ }
24073
24442
  return {
24074
- ...state,
24075
- rows,
24076
- commits,
24077
- filteredCommits,
24078
- selectedIndex: 0,
24079
- selectedFileIndex: 0,
24080
- pendingCommitFocused: false,
24081
- pendingKey: undefined,
24082
- // Rows just landed — clear the boot-loading flag so the history
24083
- // surface drops the "Loading commits…" placeholder. Safe to clear
24084
- // unconditionally because `replaceRows` only fires after a real
24085
- // git log returns.
24086
- bootLoading: false,
24443
+ noColor,
24444
+ ascii,
24445
+ borderStyle: options.borderStyle || (ascii ? 'classic' : 'round'),
24446
+ colors,
24087
24447
  };
24088
24448
  }
24089
- function appendRows(state, rows) {
24090
- const selected = getSelectedInkCommit(state);
24091
- const nextRows = [...state.rows, ...rows];
24092
- const seen = new Set();
24093
- const commits = getCommitRows(nextRows).filter((commit) => {
24094
- if (seen.has(commit.hash)) {
24095
- return false;
24449
+
24450
+ /**
24451
+ * Canned filter presets for the issue / PR triage TUI views
24452
+ * (#882 phase 6). Each preset compiles to the same shape the
24453
+ * underlying list fetchers (`getIssueList` / `getPullRequestList`)
24454
+ * already accept — there's no new `gh` surface area, just a
24455
+ * curated set of common triage angles surfaced as a single
24456
+ * keystroke (`f` cycles).
24457
+ *
24458
+ * The presets are deliberately *not* a 1:1 mirror across the two
24459
+ * surfaces:
24460
+ *
24461
+ * - Issues have no draft / mergeable concept, so `draft` /
24462
+ * `mergeable` are skipped on the issue list.
24463
+ * - PRs have a `merged` state distinct from `closed`; issues
24464
+ * don't.
24465
+ * - `mine` semantics differ subtly: for issues it tends to
24466
+ * mean "I'm the assignee" (issues are tasks people pick up);
24467
+ * for PRs it means "I'm the author" (PRs are work people
24468
+ * post). The presets bake those in so the user doesn't have
24469
+ * to think about it.
24470
+ */
24471
+ /** Cycle order — must match the keystroke walk on `f`. */
24472
+ const ISSUE_FILTER_PRESETS = [
24473
+ 'open',
24474
+ 'closed',
24475
+ 'mine',
24476
+ 'assigned',
24477
+ ];
24478
+ const PULL_REQUEST_FILTER_PRESETS = [
24479
+ 'open',
24480
+ 'draft',
24481
+ 'mine',
24482
+ 'assigned',
24483
+ 'closed',
24484
+ 'merged',
24485
+ ];
24486
+ const ISSUE_FILTER_LABELS = {
24487
+ open: 'open',
24488
+ closed: 'closed',
24489
+ mine: 'mine (assigned)',
24490
+ assigned: 'assigned to me',
24491
+ };
24492
+ const PULL_REQUEST_FILTER_LABELS = {
24493
+ open: 'open',
24494
+ draft: 'draft',
24495
+ mine: 'mine (authored)',
24496
+ assigned: 'assigned to me',
24497
+ closed: 'closed',
24498
+ merged: 'merged',
24499
+ };
24500
+ /**
24501
+ * Resolve a preset to the filter object the data fetcher accepts.
24502
+ * Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
24503
+ * `getPullRequestList` so unit tests can assert the mapping
24504
+ * independently from the fetch pipeline.
24505
+ */
24506
+ function issueFilterForPreset(preset) {
24507
+ switch (preset) {
24508
+ case 'open':
24509
+ return { state: 'open' };
24510
+ case 'closed':
24511
+ return { state: 'closed' };
24512
+ case 'mine':
24513
+ // Issues are tasks — "mine" is what *I'm working on*, i.e.
24514
+ // assigned to me + still open. Same as `assigned` plus the
24515
+ // open-state filter for ergonomic single-keystroke focus on
24516
+ // the active backlog.
24517
+ return { state: 'open', assignee: '@me' };
24518
+ case 'assigned':
24519
+ return { assignee: '@me' };
24520
+ }
24521
+ }
24522
+ function pullRequestFilterForPreset(preset) {
24523
+ switch (preset) {
24524
+ case 'open':
24525
+ return { state: 'open' };
24526
+ case 'draft':
24527
+ // gh's `--draft` flag implies `--state open`; surface that
24528
+ // explicitly so the canonicalize step doesn't elide it.
24529
+ return { state: 'open', draft: true };
24530
+ case 'mine':
24531
+ // PRs are work — "mine" is what *I authored*. Most useful
24532
+ // when looking at one's own backlog of in-flight PRs.
24533
+ return { state: 'open', author: '@me' };
24534
+ case 'assigned':
24535
+ return { assignee: '@me' };
24536
+ case 'closed':
24537
+ return { state: 'closed' };
24538
+ case 'merged':
24539
+ return { state: 'merged' };
24540
+ }
24541
+ }
24542
+ function cycleIssueFilterPreset(current) {
24543
+ const index = ISSUE_FILTER_PRESETS.indexOf(current);
24544
+ const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
24545
+ return ISSUE_FILTER_PRESETS[next];
24546
+ }
24547
+ function cyclePullRequestFilterPreset(current) {
24548
+ const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
24549
+ const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
24550
+ return PULL_REQUEST_FILTER_PRESETS[next];
24551
+ }
24552
+
24553
+ /**
24554
+ * Sort modes for the promoted views (P4.2).
24555
+ *
24556
+ * Pure: takes existing context entries + the active mode, returns a sorted
24557
+ * copy. Tested in isolation; the runtime just calls these helpers.
24558
+ *
24559
+ * Display label uses `▼` (U+25BC) under truecolor / UTF-8 and falls back to
24560
+ * `v` under ASCII. Letters (`recent` / `name` / `ahead`) carry meaning;
24561
+ * shape enhances.
24562
+ */
24563
+ const BRANCH_SORT_MODES = ['name', 'recent', 'ahead'];
24564
+ const DEFAULT_BRANCH_SORT_MODE = 'name';
24565
+ function cycleBranchSort(mode) {
24566
+ const index = BRANCH_SORT_MODES.indexOf(mode);
24567
+ if (index < 0)
24568
+ return BRANCH_SORT_MODES[0];
24569
+ return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
24570
+ }
24571
+ function sortBranches(branches, mode) {
24572
+ // Pin the current branch at index 0 regardless of sort mode (#806
24573
+ // follow-up). Lands the user's cursor on the active branch by
24574
+ // default and keeps the most-relevant row glued to the top of the
24575
+ // list as they cycle sorts.
24576
+ const current = branches.find((entry) => entry.current);
24577
+ const rest = branches.filter((entry) => !entry.current);
24578
+ const sortedRest = rest.slice();
24579
+ switch (mode) {
24580
+ case 'name':
24581
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
24582
+ break;
24583
+ case 'recent':
24584
+ // ISO-shaped dates compare byte-for-byte; descending so the freshest
24585
+ // branch sits at the top.
24586
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
24587
+ a.shortName.localeCompare(b.shortName));
24588
+ break;
24589
+ case 'ahead':
24590
+ // ahead-first; ties broken by behind, then by name. Keeps "this branch
24591
+ // has unmerged work" in the user's first scroll.
24592
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
24593
+ a.shortName.localeCompare(b.shortName));
24594
+ break;
24595
+ }
24596
+ return current ? [current, ...sortedRest] : sortedRest;
24597
+ }
24598
+ const TAG_SORT_MODES = ['recent', 'name'];
24599
+ const DEFAULT_TAG_SORT_MODE = 'recent';
24600
+ function cycleTagSort(mode) {
24601
+ const index = TAG_SORT_MODES.indexOf(mode);
24602
+ if (index < 0)
24603
+ return TAG_SORT_MODES[0];
24604
+ return TAG_SORT_MODES[(index + 1) % TAG_SORT_MODES.length];
24605
+ }
24606
+ function sortTags(tags, mode) {
24607
+ const copy = tags.slice();
24608
+ switch (mode) {
24609
+ case 'name':
24610
+ return copy.sort((a, b) => a.name.localeCompare(b.name));
24611
+ case 'recent':
24612
+ return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
24613
+ a.name.localeCompare(b.name));
24614
+ default:
24615
+ return copy;
24616
+ }
24617
+ }
24618
+ /* ---------------------------- header indicator -------------------------- */
24619
+ function formatSortIndicator(mode, options = {}) {
24620
+ return `${options.ascii ? 'v' : '▼'} ${mode}`;
24621
+ }
24622
+
24623
+ const DEFAULT_CHANGELOG_VIEW_STATE = {
24624
+ status: 'idle',
24625
+ scrollOffset: 0,
24626
+ };
24627
+ const DEFAULT_LOG_INK_STATUS_FILTER_MASK = {
24628
+ staged: true,
24629
+ unstaged: true,
24630
+ untracked: true,
24631
+ };
24632
+ /**
24633
+ * Detect a history server-side filter prefix (#776). Returns the parsed
24634
+ * `LogInkHistoryFetchArgs` for `path:<value>` and `author:<value>`
24635
+ * prefixes, or `undefined` for a plain (client-side) filter. The whole
24636
+ * remainder of the string (post-prefix) becomes the value — paths and
24637
+ * author names commonly contain spaces, and we don't try to parse
24638
+ * shell-like syntax.
24639
+ */
24640
+ function parseLogInkHistoryFetchPrefix(filter) {
24641
+ const trimmed = filter.trim();
24642
+ if (trimmed.startsWith('path:')) {
24643
+ const value = trimmed.slice('path:'.length).trim();
24644
+ return value ? { path: value } : undefined;
24645
+ }
24646
+ if (trimmed.startsWith('author:')) {
24647
+ const value = trimmed.slice('author:'.length).trim();
24648
+ return value ? { author: value } : undefined;
24649
+ }
24650
+ return undefined;
24651
+ }
24652
+ const FOCUS_ORDER = ['sidebar', 'commits', 'detail'];
24653
+ const SIDEBAR_TABS = ['status', 'branches', 'tags', 'stashes', 'worktrees'];
24654
+ function searchableFields(commit) {
24655
+ return [
24656
+ commit.shortHash,
24657
+ commit.hash,
24658
+ commit.date,
24659
+ commit.author,
24660
+ commit.message,
24661
+ ...commit.refs,
24662
+ ];
24663
+ }
24664
+ function scoreField(field, term) {
24665
+ const value = field.toLowerCase();
24666
+ const normalized = term.toLowerCase();
24667
+ if (!normalized) {
24668
+ return 0;
24669
+ }
24670
+ if (value === normalized) {
24671
+ return 1000;
24672
+ }
24673
+ if (value.startsWith(normalized)) {
24674
+ return 800 - Math.min(value.length - normalized.length, 200);
24675
+ }
24676
+ const substringIndex = value.indexOf(normalized);
24677
+ if (substringIndex >= 0) {
24678
+ return 600 - Math.min(substringIndex, 200);
24679
+ }
24680
+ let searchIndex = 0;
24681
+ let distance = 0;
24682
+ for (const character of normalized) {
24683
+ const nextIndex = value.indexOf(character, searchIndex);
24684
+ if (nextIndex < 0) {
24685
+ return undefined;
24096
24686
  }
24097
- seen.add(commit.hash);
24098
- return true;
24099
- });
24100
- const filteredCommits = filterCommits(commits, state.filter);
24101
- const selectedIndex = selected
24102
- ? filteredCommits.findIndex((commit) => commit.hash === selected.hash)
24103
- : state.selectedIndex;
24687
+ distance += nextIndex - searchIndex;
24688
+ searchIndex = nextIndex + 1;
24689
+ }
24690
+ return 300 - Math.min(distance, 200);
24691
+ }
24692
+ function scoreLogInkCommitFilter(commit, filter) {
24693
+ const terms = filter.trim().split(/\s+/).filter(Boolean);
24694
+ if (terms.length === 0) {
24695
+ return 0;
24696
+ }
24697
+ const fields = searchableFields(commit);
24698
+ let score = 0;
24699
+ for (const term of terms) {
24700
+ const bestFieldScore = fields.reduce((best, field) => {
24701
+ const fieldScore = scoreField(field, term);
24702
+ if (fieldScore === undefined) {
24703
+ return best;
24704
+ }
24705
+ return best === undefined ? fieldScore : Math.max(best, fieldScore);
24706
+ }, undefined);
24707
+ if (bestFieldScore === undefined) {
24708
+ return undefined;
24709
+ }
24710
+ score += bestFieldScore;
24711
+ }
24712
+ return score;
24713
+ }
24714
+ function filterCommits(commits, filter) {
24715
+ return commits
24716
+ .map((commit, index) => ({
24717
+ commit,
24718
+ index,
24719
+ score: scoreLogInkCommitFilter(commit, filter),
24720
+ }))
24721
+ .filter((entry) => entry.score !== undefined)
24722
+ .sort((a, b) => b.score - a.score || a.index - b.index)
24723
+ .map((entry) => entry.commit);
24724
+ }
24725
+ function clampIndex(index, length) {
24726
+ if (length === 0) {
24727
+ return 0;
24728
+ }
24729
+ return Math.max(0, Math.min(index, length - 1));
24730
+ }
24731
+ function cycleValue(values, current, delta) {
24732
+ const currentIndex = Math.max(0, values.indexOf(current));
24733
+ const nextIndex = (currentIndex + delta + values.length) % values.length;
24734
+ return values[nextIndex];
24735
+ }
24736
+ const HOME_VIEW = 'history';
24737
+ function topOfStack(stack) {
24738
+ return stack[stack.length - 1];
24739
+ }
24740
+ function withPushedView(state, value) {
24741
+ if (topOfStack(state.viewStack) === value) {
24742
+ return { ...state, pendingKey: undefined };
24743
+ }
24744
+ const viewStack = [...state.viewStack, value];
24104
24745
  return {
24105
24746
  ...state,
24106
- rows: nextRows,
24107
- commits,
24108
- filteredCommits,
24109
- selectedIndex: selectedIndex >= 0
24110
- ? selectedIndex
24111
- : clampIndex(state.selectedIndex, filteredCommits.length),
24747
+ activeView: value,
24748
+ viewStack,
24749
+ // The compose + status views' right detail panels already show
24750
+ // worktree info, so keeping the left sidebar on the Status tab
24751
+ // duplicates that information. Auto-switch to Branches when entering
24752
+ // either view; the user can swap back with [/] if they want.
24753
+ //
24754
+ // We update only the rendered `sidebarTab` here, never
24755
+ // `userSidebarTab`, so this auto-switch is invisible to per-repo
24756
+ // persistence and pop-view restores the previous tab.
24757
+ sidebarTab: value === 'compose' || value === 'status' ? 'branches' : state.sidebarTab,
24758
+ worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
24759
+ selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24760
+ diffSource: value === 'diff' ? state.diffSource : undefined,
24761
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
24762
+ compareHead: value === 'diff' ? state.compareHead : undefined,
24763
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24764
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24112
24765
  pendingKey: undefined,
24113
24766
  };
24114
24767
  }
24115
- function nextHunkOffset(currentOffset, hunkOffsets, delta) {
24116
- if (hunkOffsets.length === 0) {
24117
- return currentOffset;
24118
- }
24119
- if (delta > 0) {
24120
- const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
24121
- return nextOffset === undefined ? currentOffset : nextOffset;
24768
+ function withPoppedView(state) {
24769
+ if (state.viewStack.length <= 1) {
24770
+ return { ...state, pendingKey: undefined };
24122
24771
  }
24123
- const previousOffset = [...hunkOffsets].reverse().find((offset) => offset < currentOffset);
24124
- return previousOffset === undefined ? currentOffset : previousOffset;
24125
- }
24126
- function nextHunkIndex(currentOffset, hunkOffsets, delta) {
24127
- const offset = nextHunkOffset(currentOffset, hunkOffsets, delta);
24128
- return Math.max(0, hunkOffsets.indexOf(offset));
24129
- }
24130
- function getLogInkSidebarTabs() {
24131
- return [...SIDEBAR_TABS];
24772
+ const viewStack = state.viewStack.slice(0, -1);
24773
+ const next = topOfStack(viewStack);
24774
+ // #779 — compareBase is "cleared when the diff view is popped." We
24775
+ // detect that case by checking if the *previous* top was 'diff'.
24776
+ // The compare workflow ends when the user backs out of the compare
24777
+ // diff; on the next mark they re-set the base. Other view pops
24778
+ // preserve compareBase so the user can move between branches / tags /
24779
+ // history while hunting for a head ref.
24780
+ const wasOnDiff = state.activeView === 'diff';
24781
+ return {
24782
+ ...state,
24783
+ activeView: next,
24784
+ viewStack,
24785
+ // Restore the user's last explicit tab choice so popping out of
24786
+ // compose / status (which auto-switch the sidebar to Branches)
24787
+ // returns the user to whatever they actually had open before.
24788
+ sidebarTab: state.userSidebarTab,
24789
+ worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
24790
+ selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24791
+ diffSource: next === 'diff' ? state.diffSource : undefined,
24792
+ stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
24793
+ compareBase: wasOnDiff ? undefined : state.compareBase,
24794
+ compareHead: next === 'diff' ? state.compareHead : undefined,
24795
+ pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
24796
+ statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
24797
+ pendingKey: undefined,
24798
+ };
24132
24799
  }
24133
- function createLogInkState(rows, options = {}) {
24134
- const commits = getCommitRows(rows);
24135
- const initialView = options.activeView || 'history';
24800
+ /**
24801
+ * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
24802
+ * the active view position into the new frame's `parentReturn` so a
24803
+ * subsequent pop lands the user back where they came from, then
24804
+ * resets the per-frame navigation state (active view, view stack,
24805
+ * row / file / submodule cursors, filter) so the nested frame opens
24806
+ * in a clean slate — the mental equivalent of a fresh `coco ui`
24807
+ * launched against the submodule's working dir.
24808
+ *
24809
+ * Sidebar tab + branch / tag sort are also captured into the return
24810
+ * snapshot (#995) so popping back restores the parent's choices
24811
+ * instead of letting the submodule's tab/sort bleed across the
24812
+ * boundary. The values on the *new* frame are left as-is (carried
24813
+ * over from the parent) — the load effect in app.ts re-reads
24814
+ * persistence keyed on the submodule's workdir and dispatches a
24815
+ * restore if the user has a submodule-specific saved preference.
24816
+ *
24817
+ * Other preferences (palette recents, inspector tab, diff view mode)
24818
+ * stay global by design — the user's preference shouldn't reset when
24819
+ * they cross a submodule boundary.
24820
+ *
24821
+ * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
24822
+ * outside the reducer in `app.ts`'s parallel ref structure — this
24823
+ * helper only manages the pure view-model side of the push.
24824
+ */
24825
+ function withPushedRepoFrame(state, payload) {
24826
+ const newFrame = {
24827
+ label: payload.label,
24828
+ workdir: payload.workdir,
24829
+ entryRange: payload.entryRange,
24830
+ parentReturn: {
24831
+ activeView: state.activeView,
24832
+ selectedIndex: state.selectedIndex,
24833
+ selectedFileIndex: state.selectedFileIndex,
24834
+ selectedSubmoduleIndex: state.selectedSubmoduleIndex,
24835
+ filter: state.filter,
24836
+ sidebarTab: state.sidebarTab,
24837
+ userSidebarTab: state.userSidebarTab,
24838
+ branchSort: state.branchSort,
24839
+ tagSort: state.tagSort,
24840
+ },
24841
+ };
24136
24842
  return {
24137
- activeView: initialView,
24138
- viewStack: [initialView],
24139
- rows,
24140
- commits,
24141
- filteredCommits: commits,
24843
+ ...state,
24844
+ repoStack: [...state.repoStack, newFrame],
24845
+ activeView: 'history',
24846
+ viewStack: ['history'],
24142
24847
  selectedIndex: 0,
24143
24848
  selectedFileIndex: 0,
24144
- selectedWorktreeFileIndex: 0,
24145
- selectedWorktreeHunkIndex: 0,
24146
- selectedBranchIndex: 0,
24147
- selectedTagIndex: 0,
24148
- selectedStashIndex: 0,
24149
- selectedWorktreeListIndex: 0,
24150
- selectedConflictFileIndex: 0,
24151
- selectedReflogIndex: 0,
24152
24849
  selectedSubmoduleIndex: 0,
24153
- selectedIssueIndex: 0,
24154
- selectedPullRequestTriageIndex: 0,
24155
- selectedIssueFilter: 'open',
24156
- selectedPullRequestFilter: 'open',
24157
- repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
24158
- branchSort: DEFAULT_BRANCH_SORT_MODE,
24159
- tagSort: DEFAULT_TAG_SORT_MODE,
24160
- paletteFilter: '',
24161
- paletteSelectedIndex: 0,
24162
- paletteRecent: [],
24163
- commitCompose: createCommitComposeState(),
24164
- diffPreviewOffset: 0,
24165
- worktreeDiffOffset: 0,
24166
24850
  filter: '',
24167
24851
  filterMode: false,
24168
- // Default to the full multi-ref graph (`git log --all`) so users
24169
- // see how branches, tags, and stashes weave through the history
24170
- // out of the box. Pre-0.54.x this defaulted to false (current
24171
- // branch only); user feedback consistently asked for the
24172
- // GitKraken-style "see everything" view as the starting state.
24173
- // The `\` toggle still flips back to compact / current-branch
24174
- // mode for users who want the cleaner single-line graph. Tests
24175
- // override via `options.fullGraph` when they need the compact
24176
- // case explicitly.
24177
- fullGraph: options.fullGraph ?? true,
24178
- showHelp: false,
24179
- helpScrollOffset: 0,
24180
- showCommandPalette: false,
24181
- workflowActionId: undefined,
24852
+ pendingCommitFocused: false,
24853
+ pendingKey: undefined,
24182
24854
  pendingConfirmationId: undefined,
24183
24855
  pendingConfirmationPayload: undefined,
24184
24856
  pendingMutationConfirmation: undefined,
24185
- pendingKey: undefined,
24857
+ };
24858
+ }
24859
+ /**
24860
+ * Pop the top repo frame off `state.repoStack` (#931) and restore
24861
+ * the parent's view position from the captured `parentReturn`. A
24862
+ * no-op when the stack is already at its single root frame so this
24863
+ * action is safe to dispatch from generic input handlers (e.g. the
24864
+ * Esc auto-pop wiring that lands in a follow-up PR).
24865
+ *
24866
+ * The defensive `parentReturn` fallback handles the never-supposed-
24867
+ * to-happen case where a non-root frame somehow has no return state
24868
+ * recorded — drop the frame but leave the user's view position
24869
+ * alone rather than crash mid-session.
24870
+ */
24871
+ function withPoppedRepoFrame(state) {
24872
+ if (state.repoStack.length <= 1) {
24873
+ return { ...state, pendingKey: undefined };
24874
+ }
24875
+ const topFrame = state.repoStack[state.repoStack.length - 1];
24876
+ const ret = topFrame.parentReturn;
24877
+ const repoStack = state.repoStack.slice(0, -1);
24878
+ if (!ret) {
24879
+ return { ...state, repoStack, pendingKey: undefined };
24880
+ }
24881
+ return {
24882
+ ...state,
24883
+ repoStack,
24884
+ activeView: ret.activeView,
24885
+ viewStack: [ret.activeView],
24886
+ selectedIndex: ret.selectedIndex,
24887
+ selectedFileIndex: ret.selectedFileIndex,
24888
+ selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
24889
+ filter: ret.filter,
24890
+ filterMode: false,
24891
+ pendingCommitFocused: false,
24892
+ // #995 — restore sidebar tab + sort preferences from the captured
24893
+ // parentReturn. Without this, the submodule's tab / sort choice
24894
+ // bleeds back into the parent after pop: the user picks 'tags' in
24895
+ // a vendored submodule, pops back to the parent, and finds the
24896
+ // parent's previously-selected 'branches' tab quietly replaced.
24897
+ sidebarTab: ret.sidebarTab,
24898
+ userSidebarTab: ret.userSidebarTab,
24899
+ branchSort: ret.branchSort,
24900
+ tagSort: ret.tagSort,
24901
+ pendingKey: undefined,
24902
+ pendingConfirmationId: undefined,
24903
+ pendingConfirmationPayload: undefined,
24904
+ pendingMutationConfirmation: undefined,
24905
+ };
24906
+ }
24907
+ function withReplacedView(state, value) {
24908
+ if (topOfStack(state.viewStack) === value) {
24909
+ return { ...state, pendingKey: undefined };
24910
+ }
24911
+ const viewStack = [...state.viewStack.slice(0, -1), value];
24912
+ return {
24913
+ ...state,
24914
+ activeView: value,
24915
+ viewStack,
24916
+ worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
24917
+ selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24918
+ diffSource: value === 'diff' ? state.diffSource : undefined,
24919
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
24920
+ compareHead: value === 'diff' ? state.compareHead : undefined,
24921
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24922
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24923
+ pendingKey: undefined,
24924
+ };
24925
+ }
24926
+ function withFilter(state, filter, promotedSelections) {
24927
+ const filteredCommits = filterCommits(state.commits, filter);
24928
+ // P4.5: rectify promoted-view selections when the filter changes. Prefer
24929
+ // the runtime-supplied snapshot — which preserves the cursor on the same
24930
+ // item when it's still in the filtered list and only snaps to result[0]
24931
+ // when the previously-selected item dropped out. Falls back to the older
24932
+ // "snap to 0" behavior when no snapshot was provided (test paths,
24933
+ // dispatchers without context).
24934
+ const filterChanged = state.filter !== filter;
24935
+ const branchIndex = promotedSelections?.branchIndex ??
24936
+ (filterChanged ? 0 : state.selectedBranchIndex);
24937
+ const tagIndex = promotedSelections?.tagIndex ??
24938
+ (filterChanged ? 0 : state.selectedTagIndex);
24939
+ const stashIndex = promotedSelections?.stashIndex ??
24940
+ (filterChanged ? 0 : state.selectedStashIndex);
24941
+ // Reflog (#781) snaps to 0 on filter change rather than rectifying.
24942
+ // The list is chronological and the user is unlikely to be tracking
24943
+ // a specific entry through filter changes — the simpler reset
24944
+ // matches the "find recovery target by typing" interaction.
24945
+ const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
24946
+ return {
24947
+ ...state,
24948
+ filter,
24949
+ filteredCommits,
24950
+ selectedIndex: clampIndex(state.selectedIndex, filteredCommits.length),
24951
+ selectedFileIndex: 0,
24952
+ selectedBranchIndex: branchIndex,
24953
+ selectedTagIndex: tagIndex,
24954
+ selectedStashIndex: stashIndex,
24955
+ selectedReflogIndex: reflogIndex,
24956
+ diffPreviewOffset: 0,
24957
+ pendingKey: undefined,
24958
+ };
24959
+ }
24960
+ function replaceRows(state, rows) {
24961
+ // Wholesale row replacement after a server-side re-fetch (#776).
24962
+ // Resets the cursor to the top because the new commit set may not
24963
+ // share any hashes with the old one (e.g. switching from `--all` to
24964
+ // `-- some/path` typically dumps the previous selection).
24965
+ const commits = getCommitRows(rows);
24966
+ const filteredCommits = filterCommits(commits, state.filter);
24967
+ return {
24968
+ ...state,
24969
+ rows,
24970
+ commits,
24971
+ filteredCommits,
24972
+ selectedIndex: 0,
24973
+ selectedFileIndex: 0,
24974
+ pendingCommitFocused: false,
24975
+ pendingKey: undefined,
24976
+ // Rows just landed — clear the boot-loading flag so the history
24977
+ // surface drops the "Loading commits…" placeholder. Safe to clear
24978
+ // unconditionally because `replaceRows` only fires after a real
24979
+ // git log returns.
24980
+ bootLoading: false,
24981
+ };
24982
+ }
24983
+ function appendRows(state, rows) {
24984
+ const selected = getSelectedInkCommit(state);
24985
+ const nextRows = [...state.rows, ...rows];
24986
+ const seen = new Set();
24987
+ const commits = getCommitRows(nextRows).filter((commit) => {
24988
+ if (seen.has(commit.hash)) {
24989
+ return false;
24990
+ }
24991
+ seen.add(commit.hash);
24992
+ return true;
24993
+ });
24994
+ const filteredCommits = filterCommits(commits, state.filter);
24995
+ const selectedIndex = selected
24996
+ ? filteredCommits.findIndex((commit) => commit.hash === selected.hash)
24997
+ : state.selectedIndex;
24998
+ return {
24999
+ ...state,
25000
+ rows: nextRows,
25001
+ commits,
25002
+ filteredCommits,
25003
+ selectedIndex: selectedIndex >= 0
25004
+ ? selectedIndex
25005
+ : clampIndex(state.selectedIndex, filteredCommits.length),
25006
+ pendingKey: undefined,
25007
+ };
25008
+ }
25009
+ function nextHunkOffset(currentOffset, hunkOffsets, delta) {
25010
+ if (hunkOffsets.length === 0) {
25011
+ return currentOffset;
25012
+ }
25013
+ if (delta > 0) {
25014
+ const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
25015
+ return nextOffset === undefined ? currentOffset : nextOffset;
25016
+ }
25017
+ const previousOffset = [...hunkOffsets].reverse().find((offset) => offset < currentOffset);
25018
+ return previousOffset === undefined ? currentOffset : previousOffset;
25019
+ }
25020
+ function nextHunkIndex(currentOffset, hunkOffsets, delta) {
25021
+ const offset = nextHunkOffset(currentOffset, hunkOffsets, delta);
25022
+ return Math.max(0, hunkOffsets.indexOf(offset));
25023
+ }
25024
+ function getLogInkSidebarTabs() {
25025
+ return [...SIDEBAR_TABS];
25026
+ }
25027
+ function createLogInkState(rows, options = {}) {
25028
+ const commits = getCommitRows(rows);
25029
+ const initialView = options.activeView || 'history';
25030
+ return {
25031
+ activeView: initialView,
25032
+ viewStack: [initialView],
25033
+ rows,
25034
+ commits,
25035
+ filteredCommits: commits,
25036
+ selectedIndex: 0,
25037
+ selectedFileIndex: 0,
25038
+ selectedWorktreeFileIndex: 0,
25039
+ selectedWorktreeHunkIndex: 0,
25040
+ selectedBranchIndex: 0,
25041
+ selectedTagIndex: 0,
25042
+ selectedStashIndex: 0,
25043
+ selectedWorktreeListIndex: 0,
25044
+ selectedConflictFileIndex: 0,
25045
+ selectedReflogIndex: 0,
25046
+ selectedSubmoduleIndex: 0,
25047
+ selectedIssueIndex: 0,
25048
+ selectedPullRequestTriageIndex: 0,
25049
+ selectedIssueFilter: 'open',
25050
+ selectedPullRequestFilter: 'open',
25051
+ repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
25052
+ branchSort: DEFAULT_BRANCH_SORT_MODE,
25053
+ tagSort: DEFAULT_TAG_SORT_MODE,
25054
+ paletteFilter: '',
25055
+ paletteSelectedIndex: 0,
25056
+ paletteRecent: [],
25057
+ showThemePicker: false,
25058
+ themePickerFilter: '',
25059
+ themePickerIndex: 0,
25060
+ commitCompose: createCommitComposeState(),
25061
+ diffPreviewOffset: 0,
25062
+ worktreeDiffOffset: 0,
25063
+ filter: '',
25064
+ filterMode: false,
25065
+ // Default to the full multi-ref graph (`git log --all`) so users
25066
+ // see how branches, tags, and stashes weave through the history
25067
+ // out of the box. Pre-0.54.x this defaulted to false (current
25068
+ // branch only); user feedback consistently asked for the
25069
+ // GitKraken-style "see everything" view as the starting state.
25070
+ // The `\` toggle still flips back to compact / current-branch
25071
+ // mode for users who want the cleaner single-line graph. Tests
25072
+ // override via `options.fullGraph` when they need the compact
25073
+ // case explicitly.
25074
+ fullGraph: options.fullGraph ?? true,
25075
+ showHelp: false,
25076
+ helpScrollOffset: 0,
25077
+ showCommandPalette: false,
25078
+ workflowActionId: undefined,
25079
+ pendingConfirmationId: undefined,
25080
+ pendingConfirmationPayload: undefined,
25081
+ pendingMutationConfirmation: undefined,
25082
+ pendingKey: undefined,
24186
25083
  focus: 'commits',
24187
25084
  // Default first-time tab is 'branches' — it's the most useful
24188
25085
  // landing surface in the workstation (current branch + recent
@@ -24921,6 +25818,46 @@ function applyLogInkAction(state, action) {
24921
25818
  pendingKey: undefined,
24922
25819
  };
24923
25820
  }
25821
+ case 'toggleThemePicker': {
25822
+ const opening = !state.showThemePicker;
25823
+ return {
25824
+ ...state,
25825
+ showThemePicker: opening,
25826
+ // Only one overlay at a time — close help / palette on open.
25827
+ showHelp: false,
25828
+ showCommandPalette: false,
25829
+ themePickerFilter: '',
25830
+ themePickerIndex: 0,
25831
+ pendingKey: undefined,
25832
+ };
25833
+ }
25834
+ case 'moveThemePicker':
25835
+ return {
25836
+ ...state,
25837
+ themePickerIndex: clampIndex(state.themePickerIndex + action.delta, action.presetCount),
25838
+ pendingKey: undefined,
25839
+ };
25840
+ case 'appendThemePickerFilter':
25841
+ return {
25842
+ ...state,
25843
+ themePickerFilter: `${state.themePickerFilter}${action.value}`,
25844
+ themePickerIndex: 0,
25845
+ pendingKey: undefined,
25846
+ };
25847
+ case 'backspaceThemePickerFilter':
25848
+ return {
25849
+ ...state,
25850
+ themePickerFilter: state.themePickerFilter.slice(0, -1),
25851
+ themePickerIndex: 0,
25852
+ pendingKey: undefined,
25853
+ };
25854
+ case 'clearThemePickerFilter':
25855
+ return {
25856
+ ...state,
25857
+ themePickerFilter: '',
25858
+ themePickerIndex: 0,
25859
+ pendingKey: undefined,
25860
+ };
24924
25861
  case 'setChangelogLoading':
24925
25862
  return {
24926
25863
  ...state,
@@ -25128,6 +26065,71 @@ function applyLogInkAction(state, action) {
25128
26065
  return state;
25129
26066
  }
25130
26067
  }
26068
+ /**
26069
+ * Fuzzy (subsequence) score for a preset id against a lowercase query.
26070
+ * Returns `null` when the query chars don't appear in order; otherwise a
26071
+ * score where contiguous runs, a start-of-string match, and matches right
26072
+ * after a `-` separator are rewarded — so `gl` ranks `gruvbox-light` /
26073
+ * `github-light` above incidental matches, and `tn` finds `tokyo-night`.
26074
+ */
26075
+ function fuzzyScoreThemePreset(preset, query) {
26076
+ const target = preset.toLowerCase();
26077
+ let qi = 0;
26078
+ let score = 0;
26079
+ let lastMatch = -2;
26080
+ for (let i = 0; i < target.length && qi < query.length; i += 1) {
26081
+ if (target[i] === query[qi]) {
26082
+ score += 1;
26083
+ if (i === lastMatch + 1)
26084
+ score += 4; // contiguous run
26085
+ if (i === 0)
26086
+ score += 8; // matches the very start
26087
+ else if (target[i - 1] === '-')
26088
+ score += 4; // start of a word segment
26089
+ lastMatch = i;
26090
+ qi += 1;
26091
+ }
26092
+ }
26093
+ return qi === query.length ? score : null;
26094
+ }
26095
+ /**
26096
+ * Filter the full preset list by a fuzzy (subsequence) query, ranked best
26097
+ * match first (ties broken by catalog order). An empty query returns every
26098
+ * preset in catalog order. Shared by the theme picker overlay renderer, the
26099
+ * input handler (for cursor bounds), and the live-preview selector so all
26100
+ * three agree on the same filtered list.
26101
+ */
26102
+ function filterThemePresets(filter) {
26103
+ const query = filter.trim().toLowerCase();
26104
+ const all = getLogInkThemePresets();
26105
+ if (!query) {
26106
+ return all;
26107
+ }
26108
+ return all
26109
+ .map((preset, index) => ({ preset, index, score: fuzzyScoreThemePreset(preset, query) }))
26110
+ .filter((entry) => entry.score !== null)
26111
+ .sort((a, b) => b.score - a.score || a.index - b.index)
26112
+ .map((entry) => entry.preset);
26113
+ }
26114
+ /**
26115
+ * The preset currently under the theme-picker cursor (clamped to the
26116
+ * filtered list). `undefined` when the filter matches nothing.
26117
+ */
26118
+ function getThemePickerSelection(state) {
26119
+ return getThemePickerSelectionFor(state.themePickerFilter, state.themePickerIndex);
26120
+ }
26121
+ /**
26122
+ * State-model-agnostic variant: the preset under the picker cursor for a
26123
+ * raw `filter` + `index`. Used by the workspace top-level surface, which
26124
+ * keeps its own state shape but shares the picker filtering.
26125
+ */
26126
+ function getThemePickerSelectionFor(filter, index) {
26127
+ const filtered = filterThemePresets(filter);
26128
+ if (filtered.length === 0) {
26129
+ return undefined;
26130
+ }
26131
+ return filtered[clampIndex(index, filtered.length)];
26132
+ }
25131
26133
 
25132
26134
  /**
25133
26135
  * In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
@@ -25589,6 +26591,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
25589
26591
  case 'commandPalette':
25590
26592
  // Re-toggling closes; the dispatcher will close after execute anyway.
25591
26593
  return [];
26594
+ case 'themePicker':
26595
+ // Palette closes on execute (toggleCommandPalette runs first), then
26596
+ // this opens the theme picker.
26597
+ return [action({ type: 'toggleThemePicker' })];
25592
26598
  case 'workflowDeleteBranch':
25593
26599
  case 'workflowDeleteTag':
25594
26600
  case 'workflowDropStash':
@@ -26121,6 +27127,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26121
27127
  // overlay open without dispatching any state change.
26122
27128
  return [];
26123
27129
  }
27130
+ if (state.showThemePicker) {
27131
+ const filtered = filterThemePresets(state.themePickerFilter);
27132
+ if (key.escape) {
27133
+ // Two-stage Esc: clear a non-empty filter first, then close (and
27134
+ // revert the live preview to the previously-active theme).
27135
+ if (state.themePickerFilter.length > 0) {
27136
+ return [action({ type: 'clearThemePickerFilter' })];
27137
+ }
27138
+ return [action({ type: 'toggleThemePicker' })];
27139
+ }
27140
+ if (key.return) {
27141
+ const selected = getThemePickerSelection(state);
27142
+ if (!selected) {
27143
+ return [action({ type: 'toggleThemePicker' })];
27144
+ }
27145
+ return [
27146
+ action({ type: 'toggleThemePicker' }),
27147
+ { type: 'applyThemePreset', preset: selected },
27148
+ ];
27149
+ }
27150
+ if (key.upArrow || (key.ctrl && inputValue === 'p')) {
27151
+ return [action({ type: 'moveThemePicker', delta: -1, presetCount: filtered.length })];
27152
+ }
27153
+ if (key.downArrow || (key.ctrl && inputValue === 'n')) {
27154
+ return [action({ type: 'moveThemePicker', delta: 1, presetCount: filtered.length })];
27155
+ }
27156
+ if (key.backspace || key.delete) {
27157
+ return [action({ type: 'backspaceThemePickerFilter' })];
27158
+ }
27159
+ if (key.ctrl && inputValue === 'u') {
27160
+ return [action({ type: 'clearThemePickerFilter' })];
27161
+ }
27162
+ // All other printable input filters the list (so `j`/`k` type into the
27163
+ // filter rather than navigating — matching the command palette).
27164
+ if (inputValue && !key.ctrl && !key.meta) {
27165
+ return [action({ type: 'appendThemePickerFilter', value: inputValue })];
27166
+ }
27167
+ return [];
27168
+ }
26124
27169
  if (state.showCommandPalette) {
26125
27170
  const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
26126
27171
  if (key.escape) {
@@ -26391,6 +27436,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26391
27436
  action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
26392
27437
  ];
26393
27438
  }
27439
+ // gC — open the theme picker (browse + live-preview + apply a color theme).
27440
+ if (state.pendingKey === 'g' && inputValue === 'C') {
27441
+ return [
27442
+ action({ type: 'setPendingKey', value: undefined }),
27443
+ action({ type: 'toggleThemePicker' }),
27444
+ ];
27445
+ }
26394
27446
  // #784 — bisect view action keys. Scoped to `state.activeView ===
26395
27447
  // 'bisect' && state.focus === 'commits'` so the single-letter keys
26396
27448
  // stay free everywhere else. `g` and `b` collide with the global
@@ -27862,6 +28914,60 @@ function markOnboardingSeen() {
27862
28914
  }
27863
28915
  }
27864
28916
 
28917
+ /**
28918
+ * Persist the user's chosen `coco ui` theme preset to the global XDG
28919
+ * config (`$XDG_CONFIG_HOME/coco/config.json`, default `~/.config/...`),
28920
+ * so a theme picked in the workstation sticks across every repo and
28921
+ * launch. This is the same file `loadXDGConfig` reads.
28922
+ *
28923
+ * Read-modify-write that preserves every other key (we only touch
28924
+ * `logTui.theme.preset`), unlike the whole-object project-config writer.
28925
+ * Best-effort: a read-only HOME or malformed file never throws — the
28926
+ * picker still applies the theme for the session.
28927
+ */
28928
+ const VALID_PRESETS = new Set(getLogInkThemePresets());
28929
+ function getXdgConfigPath() {
28930
+ const home = process.env.XDG_CONFIG_HOME || path__namespace$1.join(os__namespace$1.homedir(), '.config');
28931
+ return path__namespace$1.join(home, 'coco', 'config.json');
28932
+ }
28933
+ function isRecord(value) {
28934
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28935
+ }
28936
+ /**
28937
+ * Write `logTui.theme.preset = <preset>` into the global config, merging
28938
+ * into any existing content. Returns `true` on success, `false` if the
28939
+ * preset is unknown or the write failed (caller treats failure as
28940
+ * "applied for this session only").
28941
+ */
28942
+ function saveThemePreset(preset) {
28943
+ if (!VALID_PRESETS.has(preset)) {
28944
+ return false;
28945
+ }
28946
+ const file = getXdgConfigPath();
28947
+ try {
28948
+ let config = {};
28949
+ try {
28950
+ const parsed = JSON.parse(fs__namespace$1.readFileSync(file, 'utf8'));
28951
+ if (isRecord(parsed)) {
28952
+ config = parsed;
28953
+ }
28954
+ }
28955
+ catch {
28956
+ // No existing file (or unreadable/malformed) — start fresh.
28957
+ config = {};
28958
+ }
28959
+ const logTui = isRecord(config.logTui) ? config.logTui : {};
28960
+ const theme = isRecord(logTui.theme) ? logTui.theme : {};
28961
+ config.logTui = { ...logTui, theme: { ...theme, preset } };
28962
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
28963
+ fs__namespace$1.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
28964
+ return true;
28965
+ }
28966
+ catch {
28967
+ return false;
28968
+ }
28969
+ }
28970
+
27865
28971
  /**
27866
28972
  * Status-line hints for "what to do next" after a workflow that
27867
28973
  * mutates the worktree (split-apply, etc.). Pure formatting — the
@@ -32197,6 +33303,10 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
32197
33303
  // row's dim and read as quiet chrome.
32198
33304
  h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
32199
33305
  });
33306
+ // Scroll indicators — same "N more above/below" pattern as the
33307
+ // sidebar and help overlay so the user knows the list continues.
33308
+ const branchesHasMoreAbove = startIndex > 0 && localBranches.length > 0;
33309
+ const branchesHasMoreBelow = startIndex + listRows < localBranches.length;
32200
33310
  return h(Box, {
32201
33311
  borderColor: focusBorderColor(theme, focused),
32202
33312
  borderStyle: theme.borderStyle,
@@ -32204,7 +33314,11 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
32204
33314
  flexShrink: 0,
32205
33315
  paddingX: 1,
32206
33316
  width,
32207
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
33317
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(branchesHasMoreAbove
33318
+ ? [h(Text, { key: 'branches-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
33319
+ : []), ...lines, ...(branchesHasMoreBelow
33320
+ ? [h(Text, { key: 'branches-more-below', dimColor: true }, ` ↓ ${localBranches.length - (startIndex + listRows)} more below`)]
33321
+ : []));
32208
33322
  }
32209
33323
 
32210
33324
  /**
@@ -32977,12 +34091,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
32977
34091
  // sees at a glance which file the cursor is inside.
32978
34092
  const isActive = absoluteIndex === activeStartLine;
32979
34093
  const arrow = theme.ascii ? '> ' : '▾ ';
34094
+ const activeHeader = isActive && focused && !theme.noColor;
32980
34095
  return h(Text, {
32981
34096
  key: `stash-diff-line-${absoluteIndex}`,
32982
34097
  bold: true,
32983
- color: theme.noColor ? undefined : theme.colors.accent,
32984
- backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
32985
- inverse: isActive && focused,
34098
+ // Active header sits on the selection bar with a
34099
+ // contrast-guaranteed foreground (matches history/status).
34100
+ // The old `inverse` swap turned the accent into the bar and
34101
+ // left the path in the selection color — low-contrast on
34102
+ // light themes (e.g. accent blue bar + light-gray text).
34103
+ color: activeHeader
34104
+ ? theme.colors.selectionForeground
34105
+ : (theme.noColor ? undefined : theme.colors.accent),
34106
+ backgroundColor: activeHeader ? theme.colors.selection : undefined,
32986
34107
  }, (() => {
32987
34108
  // Smart path truncation for the diff file header: keep
32988
34109
  // the leading arrow glyph and elide middle path
@@ -34060,7 +35181,7 @@ function formatHistoryFetchArgs(args) {
34060
35181
  * Returns the spans flat so the caller can splat them into the row's
34061
35182
  * outer Text alongside other segments without an extra wrapper.
34062
35183
  */
34063
- function renderTypedSubject(h, Text, text, theme, key) {
35184
+ function renderTypedSubject(h, Text, text, theme, key, suppressColor = false) {
34064
35185
  const parsed = parseConventionalCommitPrefix(text);
34065
35186
  if (!parsed) {
34066
35187
  return [h(Text, { key: `${key}-msg` }, text)];
@@ -34068,7 +35189,9 @@ function renderTypedSubject(h, Text, text, theme, key) {
34068
35189
  if (text.length < parsed.prefix.length) {
34069
35190
  return [h(Text, { key: `${key}-msg` }, text)];
34070
35191
  }
34071
- const color = getConventionalCommitColor(parsed, theme);
35192
+ // When the row is selected (inverted), suppress the type color so
35193
+ // text inherits the dark inverted foreground and stays readable.
35194
+ const color = suppressColor ? undefined : getConventionalCommitColor(parsed, theme);
34072
35195
  return [
34073
35196
  h(Text, { key: `${key}-type`, color, bold: parsed.breaking }, parsed.prefix),
34074
35197
  h(Text, { key: `${key}-rest` }, text.slice(parsed.prefix.length)),
@@ -34089,15 +35212,10 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
34089
35212
  const elements = [];
34090
35213
  let totalLen = 0;
34091
35214
  segments.forEach((seg, idx) => {
34092
- const laneColor = getLaneColor(seg.laneId, theme);
35215
+ const laneColor = options.suppressColor ? undefined : (getLaneColor(seg.laneId, theme) ?? muted);
34093
35216
  elements.push(h(Text, {
34094
35217
  key: `${keyPrefix}-${idx}`,
34095
- color: laneColor ?? muted,
34096
- // Ink does not cascade dimColor from a parent Text to children,
34097
- // so the caller's "this whole row should fade" intent has to
34098
- // travel here as an explicit flag (#831). Used for graph-only
34099
- // lane-closure rows, where the lane colors otherwise compete
34100
- // for attention with the commits they connect.
35218
+ color: laneColor,
34101
35219
  dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
34102
35220
  }, seg.text));
34103
35221
  totalLen += seg.text.length;
@@ -34148,18 +35266,26 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
34148
35266
  const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
34149
35267
  const message = truncateCells(commit.message, messageRoom);
34150
35268
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
34151
- const accent = theme.noColor ? undefined : theme.colors.accent;
34152
- const muted = theme.noColor ? undefined : theme.colors.muted;
35269
+ // Don't use inverse it makes child colors unreadable. Instead, set a
35270
+ // background on the row AND an explicit, contrast-guaranteed foreground
35271
+ // (`selectionForeground`, derived from the selection bg) on the outer
35272
+ // span. Suppressing each child's own color to `undefined` then lets it
35273
+ // inherit that readable foreground — so the whole selected row stays
35274
+ // legible regardless of the user's terminal default foreground, which
35275
+ // is what the old "rely on the default fg" approach got wrong.
35276
+ const selectedFg = selected && !theme.noColor ? theme.colors.selectionForeground : undefined;
35277
+ const accent = selected ? undefined : (theme.noColor ? undefined : theme.colors.accent);
35278
+ const muted = selected ? undefined : (theme.noColor ? undefined : theme.colors.muted);
34153
35279
  // Lane-colored graph spans when full graph mode + non-ASCII rendering
34154
35280
  // is in play; otherwise fall back to the legacy single-muted span so
34155
35281
  // compact mode and legacy terminals stay visually unchanged.
34156
35282
  const graphChildren = laneSegments && !theme.ascii
34157
- ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
34158
- : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
35283
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`, { suppressColor: selected })
35284
+ : [h(Text, { color: muted, dimColor: !selected && theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
34159
35285
  return h(Text, {
34160
35286
  key: `${commit.hash}-${index}`,
34161
35287
  backgroundColor: selectedBg,
34162
- inverse: selected,
35288
+ color: selectedFg,
34163
35289
  }, ...graphChildren, ' ',
34164
35290
  // "Just landed" marker — a single thick vertical bar in the
34165
35291
  // accent color before the short hash. Fades when the runtime
@@ -34181,11 +35307,11 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
34181
35307
  // Date column drops out entirely at `tight` density — no spacer
34182
35308
  // either, so the message column slides left into the freed cells.
34183
35309
  dateText
34184
- ? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: true }, dateText, ' ')
35310
+ ? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: !selected }, dateText, ' ')
34185
35311
  : null,
34186
35312
  // Branch chip prefix (full-graph mode only) lands right before the
34187
35313
  // message so the eye reads "branch · subject" as a unit.
34188
- chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
35314
+ chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`, selected), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
34189
35315
  }
34190
35316
  /**
34191
35317
  * Stacked variant used at `rowMode='stacked'` (rail tier). Each
@@ -34200,9 +35326,13 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
34200
35326
  */
34201
35327
  function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false, remoteNames) {
34202
35328
  const totalWidth = Math.max(20, panelWidth - 4);
34203
- const accent = theme.noColor ? undefined : theme.colors.accent;
34204
- const muted = theme.noColor ? undefined : theme.colors.muted;
35329
+ // Suppress child colors on selected rows so each span inherits the
35330
+ // contrast-guaranteed `selectionForeground` set on the line-1 span,
35331
+ // keeping the selected row readable against the selection bg.
35332
+ const accent = selected ? undefined : (theme.noColor ? undefined : theme.colors.accent);
35333
+ const muted = selected ? undefined : (theme.noColor ? undefined : theme.colors.muted);
34205
35334
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
35335
+ const selectedFg = selected && !theme.noColor ? theme.colors.selectionForeground : undefined;
34206
35336
  // Line 1 — subject row. Mostly mirrors the single-line layout but
34207
35337
  // skips the date and refs so the message has the whole tail to
34208
35338
  // itself. Branch chip rides between the hash and the subject the
@@ -34214,15 +35344,15 @@ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth,
34214
35344
  const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
34215
35345
  const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
34216
35346
  const graphChildren = laneSegments && !theme.ascii
34217
- ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`)
34218
- : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
35347
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`, { suppressColor: selected })
35348
+ : [h(Text, { color: muted, dimColor: !selected && theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
34219
35349
  const lineOne = h(Text, {
34220
35350
  key: `${commit.hash}-${index}-l1`,
34221
35351
  backgroundColor: selectedBg,
34222
- inverse: selected,
35352
+ color: selectedFg,
34223
35353
  }, ...graphChildren, ' ', isRecent
34224
35354
  ? h(Text, { color: accent, bold: true }, theme.ascii ? '* ' : '▎ ')
34225
- : null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`));
35355
+ : null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`, selected));
34226
35356
  // Line 2 — metadata row, padded to align with the start of the
34227
35357
  // shortHash on line 1 so the eye still groups them as one commit.
34228
35358
  // Selection background does not extend here so we don't get a thick
@@ -34275,8 +35405,11 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
34275
35405
  return h(Text, {
34276
35406
  key: 'pending-commit-row',
34277
35407
  bold: true,
34278
- color: theme.noColor ? undefined : theme.colors.accent,
34279
- inverse: selected,
35408
+ // On selection, swap to the contrast-guaranteed foreground so the
35409
+ // accent label doesn't wash out against the selection bar.
35410
+ color: selected && !theme.noColor
35411
+ ? theme.colors.selectionForeground
35412
+ : (theme.noColor ? undefined : theme.colors.accent),
34280
35413
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
34281
35414
  }, truncateCells(label, 140));
34282
35415
  }
@@ -34690,6 +35823,10 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
34690
35823
  dimColor: !isSelected,
34691
35824
  }, truncateCells(line, width - 4));
34692
35825
  });
35826
+ // Scroll indicators for the palette list — same pattern as the
35827
+ // sidebar and help overlay so the user knows there's more content.
35828
+ const paletteHasMoreAbove = startIndex > 0 && filtered.length > 0;
35829
+ const paletteHasMoreBelow = startIndex + listRows < filtered.length;
34693
35830
  return h(Box, {
34694
35831
  borderColor: focusBorderColor(theme, focused),
34695
35832
  borderStyle: theme.borderStyle,
@@ -34698,7 +35835,66 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
34698
35835
  paddingX: 1,
34699
35836
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Command palette', focused)), h(Text, { dimColor: true }, matchSummary)), h(Text, { color: theme.colors.accent }, truncateCells(inputLine, width - 4)), h(Text, { dimColor: true }, truncateCells(hint, width - 4)), h(Text, undefined, ''), ...(showingRecent
34700
35837
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
34701
- : []), ...itemLines);
35838
+ : []), ...(paletteHasMoreAbove
35839
+ ? [h(Text, { key: 'palette-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
35840
+ : []), ...itemLines, ...(paletteHasMoreBelow
35841
+ ? [h(Text, { key: 'palette-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
35842
+ : []));
35843
+ }
35844
+ /**
35845
+ * Theme picker overlay (`gC`). Renders like the command palette so the
35846
+ * rest of the surface live-previews the cursored theme underneath. Type to
35847
+ * filter, ↑/↓ to move, Enter applies (and persists), Esc cancels. Takes the
35848
+ * raw `filter` + `index` rather than a `LogInkState` so it's reusable by
35849
+ * the workspace top-level surface, which has its own state model.
35850
+ */
35851
+ function renderThemePickerOverlay(h, components, filter, index, width, theme, focused) {
35852
+ const { Box, Text } = components;
35853
+ const filtered = filterThemePresets(filter);
35854
+ const selectedIndex = filtered.length === 0
35855
+ ? 0
35856
+ : Math.max(0, Math.min(index, filtered.length - 1));
35857
+ const listRows = 14;
35858
+ const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
35859
+ const visible = filtered.slice(startIndex, startIndex + listRows);
35860
+ const inputLine = `> ${filter}_`;
35861
+ const matchSummary = filtered.length === 0
35862
+ ? 'no matches'
35863
+ : `${filtered.length} ${filtered.length === 1 ? 'theme' : 'themes'}`;
35864
+ const hint = '↑/↓ select · type to filter · enter apply · esc close';
35865
+ const itemLines = filtered.length === 0
35866
+ ? [h(Text, { key: 'theme-empty', dimColor: true }, 'No themes match the current filter.')]
35867
+ : visible.map((preset, offset) => {
35868
+ const index = startIndex + offset;
35869
+ const isSelected = index === selectedIndex;
35870
+ const cursor = isSelected ? '>' : ' ';
35871
+ // Accent swatch per theme (no swatch for the monochrome baseline or
35872
+ // when color is off). `default` is the only ANSI-named accent.
35873
+ const accent = preset === 'monochrome'
35874
+ ? undefined
35875
+ : THEME_PRESET_COLORS[preset]?.accent;
35876
+ const swatch = accent && !theme.noColor
35877
+ ? h(Text, { key: `theme-swatch-${preset}`, color: accent }, '● ')
35878
+ : h(Text, { key: `theme-swatch-${preset}`, dimColor: true }, '· ');
35879
+ return h(Text, {
35880
+ key: `theme-${preset}`,
35881
+ bold: isSelected,
35882
+ dimColor: !isSelected,
35883
+ }, `${cursor} `, swatch, truncateCells(preset, width - 8));
35884
+ });
35885
+ const hasMoreAbove = startIndex > 0 && filtered.length > 0;
35886
+ const hasMoreBelow = startIndex + listRows < filtered.length;
35887
+ return h(Box, {
35888
+ borderColor: focusBorderColor(theme, focused),
35889
+ borderStyle: theme.borderStyle,
35890
+ flexDirection: 'column',
35891
+ width,
35892
+ paddingX: 1,
35893
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Theme picker', focused)), h(Text, { dimColor: true }, matchSummary)), h(Text, { color: theme.colors.accent }, truncateCells(inputLine, width - 4)), h(Text, { dimColor: true }, truncateCells(hint, width - 4)), h(Text, undefined, ''), ...(hasMoreAbove
35894
+ ? [h(Text, { key: 'theme-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
35895
+ : []), ...itemLines, ...(hasMoreBelow
35896
+ ? [h(Text, { key: 'theme-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
35897
+ : []));
34702
35898
  }
34703
35899
  /**
34704
35900
  * Split-plan overlay (#907) — renders the proposed commit groups for
@@ -35554,6 +36750,8 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
35554
36750
  dimColor: !isSelected,
35555
36751
  }, truncateCells(`${cursor} ${stash.ref.padEnd(12)} ${stash.message}`, 140));
35556
36752
  });
36753
+ const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
36754
+ const stashHasMoreBelow = startIndex + listRows < stashes.length;
35557
36755
  return h(Box, {
35558
36756
  borderColor: focusBorderColor(theme, focused),
35559
36757
  borderStyle: theme.borderStyle,
@@ -35561,7 +36759,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
35561
36759
  flexShrink: 0,
35562
36760
  paddingX: 1,
35563
36761
  width,
35564
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
36762
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(stashHasMoreAbove
36763
+ ? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
36764
+ : []), ...lines, ...(stashHasMoreBelow
36765
+ ? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
36766
+ : []));
35565
36767
  }
35566
36768
 
35567
36769
  /**
@@ -35657,7 +36859,7 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35657
36859
  bold: true,
35658
36860
  dimColor: !headerSelected && rowIndex > cursorRowIndex,
35659
36861
  backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
35660
- inverse: headerSelected,
36862
+ color: headerSelected && !theme.noColor ? theme.colors.selectionForeground : undefined,
35661
36863
  }, truncateCells(text, 140));
35662
36864
  }
35663
36865
  const isSelected = !headerFocused && row.flatIndex === selectedIndex;
@@ -35677,8 +36879,11 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35677
36879
  key: `status-file-${row.flatIndex}-${rowIndex}`,
35678
36880
  dimColor: !isSelected && rowIndex > cursorRowIndex,
35679
36881
  backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
35680
- inverse: isSelected && focused,
35681
- }, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
36882
+ color: isSelected && focused && !theme.noColor ? theme.colors.selectionForeground : undefined,
36883
+ }, ` ${cursorPart}`,
36884
+ // Suppress the dot's own color on selected rows so it inherits the
36885
+ // contrast-guaranteed selection foreground set on the row span.
36886
+ ...(useDot ? [h(Text, { color: (isSelected && focused) ? undefined : dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
35682
36887
  });
35683
36888
  // When the mask narrows the list to nothing but the underlying repo
35684
36889
  // is non-clean, surface why the panel looks empty so the user can
@@ -35693,6 +36898,10 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35693
36898
  : cleanHint
35694
36899
  ? [cleanHint]
35695
36900
  : ['Worktree clean'];
36901
+ // Scroll indicators for the status file list — same pattern as
36902
+ // branches and the sidebar so the user knows there's more content.
36903
+ const statusHasMoreAbove = windowStart > 0 && surfaceRows.length > 0;
36904
+ const statusHasMoreBelow = windowStart + listRows < surfaceRows.length;
35696
36905
  return h(Box, {
35697
36906
  borderColor: focusBorderColor(theme, focused),
35698
36907
  borderStyle: theme.borderStyle,
@@ -35708,7 +36917,11 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35708
36917
  // never touch the filter.
35709
36918
  ...(isStatusFilterMaskActive(state.statusFilterMask)
35710
36919
  ? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
35711
- : []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
36920
+ : []), ...(statusHasMoreAbove
36921
+ ? [h(Text, { key: 'status-more-above', dimColor: true }, ` ↑ ${windowStart} more above`)]
36922
+ : []), ...renderedRows, ...(statusHasMoreBelow
36923
+ ? [h(Text, { key: 'status-more-below', dimColor: true }, ` ↓ ${surfaceRows.length - (windowStart + listRows)} more below`)]
36924
+ : []), ...fallbackLines.map((line, index) => h(Text, {
35712
36925
  key: `status-surface-fallback-${index}`,
35713
36926
  dimColor: index > 0,
35714
36927
  }, truncateCells(line, 140))));
@@ -35938,6 +37151,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
35938
37151
  dimColor: !isSelected,
35939
37152
  }, before, formatHyperlink(namePadded, url), after);
35940
37153
  });
37154
+ const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
37155
+ const tagsHasMoreBelow = startIndex + listRows < tags.length;
35941
37156
  return h(Box, {
35942
37157
  borderColor: focusBorderColor(theme, focused),
35943
37158
  borderStyle: theme.borderStyle,
@@ -35945,7 +37160,11 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
35945
37160
  flexShrink: 0,
35946
37161
  paddingX: 1,
35947
37162
  width,
35948
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
37163
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(tagsHasMoreAbove
37164
+ ? [h(Text, { key: 'tags-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
37165
+ : []), ...lines, ...(tagsHasMoreBelow
37166
+ ? [h(Text, { key: 'tags-more-below', dimColor: true }, ` ↓ ${tags.length - (startIndex + listRows)} more below`)]
37167
+ : []));
35949
37168
  }
35950
37169
 
35951
37170
  /**
@@ -36464,12 +37683,17 @@ function renderInspectorActionsSection(h, Text, context, width, theme, options =
36464
37683
  h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
36465
37684
  ...actions.map((action, index) => {
36466
37685
  const isSelected = cursorActive && index === cursorIndex;
37686
+ // On the selected row, swap every span to the contrast-guaranteed
37687
+ // selection foreground so the key glyph / destructive marker don't
37688
+ // wash out against the selection bar; the row is already highlighted,
37689
+ // and the label text still conveys which actions are destructive.
37690
+ const selectedFg = isSelected && !theme.noColor ? theme.colors.selectionForeground : undefined;
36467
37691
  const keyCell = action.key.padEnd(KEY_COLUMN);
36468
37692
  const label = truncateCells(action.label, labelBudget);
36469
37693
  const children = [
36470
37694
  h(Text, {
36471
37695
  key: `actions-${index}-key`,
36472
- color: action.destructive ? theme.colors.danger : theme.colors.accent,
37696
+ color: selectedFg ?? (action.destructive ? theme.colors.danger : theme.colors.accent),
36473
37697
  }, keyCell),
36474
37698
  GAP,
36475
37699
  label,
@@ -36477,14 +37701,14 @@ function renderInspectorActionsSection(h, Text, context, width, theme, options =
36477
37701
  if (action.destructive) {
36478
37702
  children.push(h(Text, {
36479
37703
  key: `actions-${index}-mark`,
36480
- color: theme.colors.danger,
37704
+ color: selectedFg ?? theme.colors.danger,
36481
37705
  dimColor: false,
36482
37706
  }, DESTRUCTIVE_SUFFIX));
36483
37707
  }
36484
37708
  return h(Text, {
36485
37709
  key: `actions-${index}`,
36486
37710
  backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
36487
- inverse: isSelected,
37711
+ color: selectedFg,
36488
37712
  }, ...children);
36489
37713
  }),
36490
37714
  ];
@@ -36561,7 +37785,6 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
36561
37785
  return h(Text, {
36562
37786
  key: `commit-file-${index}`,
36563
37787
  color: statusCodeColor(file.status, theme),
36564
- inverse: isSelected && focused && !theme.noColor,
36565
37788
  bold: isSelected,
36566
37789
  }, label);
36567
37790
  });
@@ -37109,6 +38332,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
37109
38332
  if (state.showCommandPalette) {
37110
38333
  return renderCommandPalette(h, components, state, width, theme, focused);
37111
38334
  }
38335
+ if (state.showThemePicker) {
38336
+ return renderThemePickerOverlay(h, components, state.themePickerFilter, state.themePickerIndex, width, theme, focused);
38337
+ }
37112
38338
  if (state.inputPrompt) {
37113
38339
  return renderInputPromptPanel(h, components, state, width, theme, focused);
37114
38340
  }
@@ -37403,9 +38629,21 @@ function enrichFilterActionWithRectification(action, state, context) {
37403
38629
  }
37404
38630
  }
37405
38631
  function LogInkApp(deps) {
37406
- const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
38632
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme: baseTheme, themeConfig } = deps;
37407
38633
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
37408
38634
  const h = React.createElement;
38635
+ // Theme picker (gC) — live preview + apply. `themePreviewPreset` follows
38636
+ // the picker cursor while the overlay is open; `themeSessionPreset` is the
38637
+ // applied choice that survives close. The effective theme is rebuilt from
38638
+ // the original `themeConfig` so ascii/border/noColor + truecolor-downgrade
38639
+ // semantics are preserved; when neither override is set we use the static
38640
+ // `baseTheme` unchanged (so behavior is identical until the picker is used).
38641
+ const [themePreviewPreset, setThemePreviewPreset] = React.useState(undefined);
38642
+ const [themeSessionPreset, setThemeSessionPreset] = React.useState(undefined);
38643
+ const effectiveThemePreset = themePreviewPreset ?? themeSessionPreset;
38644
+ const theme = React.useMemo(() => effectiveThemePreset
38645
+ ? createLogInkTheme({ ...themeConfig, preset: effectiveThemePreset })
38646
+ : baseTheme, [effectiveThemePreset, themeConfig, baseTheme]);
37409
38647
  const { exit } = useApp();
37410
38648
  const windowSize = useWindowSize();
37411
38649
  // Bumping this on SIGCONT forces the existing tree to repaint so users
@@ -37434,6 +38672,17 @@ function LogInkApp(deps) {
37434
38672
  // immediately while the chrome still flags the refresh.
37435
38673
  bootLoading: Boolean(loadRows),
37436
38674
  }));
38675
+ // Theme picker live preview: keep `themePreviewPreset` in sync with the
38676
+ // preset under the picker cursor while the overlay is open; clear it when
38677
+ // the overlay closes so the theme reverts to the applied session preset
38678
+ // (or the original config theme). The derived-theme `useMemo` above does
38679
+ // the actual re-render from this state.
38680
+ const themePickerSelection = state.showThemePicker
38681
+ ? getThemePickerSelection(state)
38682
+ : undefined;
38683
+ React.useEffect(() => {
38684
+ setThemePreviewPreset(state.showThemePicker ? themePickerSelection : undefined);
38685
+ }, [state.showThemePicker, themePickerSelection]);
37437
38686
  // Nested-repo runtime stack (#931). Each frame holds the live
37438
38687
  // `SimpleGit`, the loaded `LogInkContext`, and the per-key load
37439
38688
  // status the chrome reads. The active (top-of-stack) entry drives
@@ -40856,946 +42105,423 @@ function LogInkApp(deps) {
40856
42105
  // (failure), surfacing the right message.
40857
42106
  }
40858
42107
  else {
40859
- dispatch({
40860
- type: 'setStatus',
40861
- value: `${target.label} target commit returned no rows — orphan ref?`,
40862
- kind: 'warning',
40863
- });
40864
- }
40865
- }
40866
- catch (error) {
40867
- if (mountedRef.current) {
40868
- dispatch({
40869
- type: 'setStatus',
40870
- value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
40871
- kind: 'error',
40872
- });
40873
- }
40874
- }
40875
- }, [dispatch, git]);
40876
- React.useEffect(() => {
40877
- loadCommitContextRef.current = loadCommitContext;
40878
- }, [loadCommitContext]);
40879
- // Server-side history filter (#776). When the user submits `path:foo`
40880
- // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
40881
- // this effect picks up the change, re-runs `getLogRows` with merged
40882
- // args, and replaces the rows. Clearing the fetch args (Ctrl+U inside
40883
- // filter mode) re-fetches with the original logArgv so the user gets
40884
- // the live full log back, not a stale snapshot of the initial rows.
40885
- const historyFetchEffectInitialized = React.useRef(false);
40886
- const historyFetchRequestRef = React.useRef(0);
40887
- React.useEffect(() => {
40888
- if (!logArgv)
40889
- return;
40890
- // Skip the first run — initial rows came in via deps.rows; we only
40891
- // want to fetch in response to *changes* to historyFetchArgs.
40892
- if (!historyFetchEffectInitialized.current) {
40893
- historyFetchEffectInitialized.current = true;
40894
- return;
40895
- }
40896
- const requestId = historyFetchRequestRef.current + 1;
40897
- historyFetchRequestRef.current = requestId;
40898
- const fetchArgs = state.historyFetchArgs;
40899
- const merged = {
40900
- ...logArgv,
40901
- ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
40902
- ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
40903
- };
40904
- const description = fetchArgs?.author
40905
- ? `author:${fetchArgs.author}`
40906
- : fetchArgs?.path
40907
- ? `path:${fetchArgs.path}`
40908
- : undefined;
40909
- dispatch({
40910
- type: 'setStatus',
40911
- value: description ? `Refetching with ${description}` : 'Restoring full log',
40912
- });
40913
- void (async () => {
40914
- const stashHashes = await getStashCommitHashes(git).catch(() => []);
40915
- const nextRows = await safe(getLogRows(git, merged, {
40916
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40917
- extraRefs: stashHashes,
40918
- }));
40919
- if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
40920
- return;
40921
- }
40922
- if (!nextRows) {
40923
- dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
40924
- return;
40925
- }
40926
- dispatch({ type: 'replaceRows', rows: nextRows });
40927
- const matched = getCommitRows(nextRows).length;
40928
- setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40929
- dispatch({
40930
- type: 'setStatus',
40931
- value: description
40932
- ? `Showing ${matched} commits matching ${description}`
40933
- : 'Showing full log',
40934
- kind: 'success',
40935
- });
40936
- })();
40937
- }, [dispatch, git, logArgv, state.historyFetchArgs]);
40938
- // Graph mode toggle (`g` key, #791 follow-up). The header label flips
40939
- // between "compact graph" and "full graph", but unless we re-fetch with
40940
- // the right `view`, the underlying rows still come from the user's
40941
- // initial argv (default `--first-parent --no-merges`) and the renderer
40942
- // has no topology to draw — defeating the per-lane / junction work.
40943
- // Mirrors the historyFetchArgs effect: skip first run, request-id ref
40944
- // for stale-completion guard, swap rows in place via replaceRows.
40945
- const toggleGraphEffectInitialized = React.useRef(false);
40946
- const toggleGraphRequestRef = React.useRef(0);
40947
- React.useEffect(() => {
40948
- if (!logArgv)
40949
- return;
40950
- if (!toggleGraphEffectInitialized.current) {
40951
- toggleGraphEffectInitialized.current = true;
40952
- return;
40953
- }
40954
- const requestId = toggleGraphRequestRef.current + 1;
40955
- toggleGraphRequestRef.current = requestId;
40956
- const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
40957
- dispatch({
40958
- type: 'setStatus',
40959
- value: state.fullGraph
40960
- ? 'Loading full topology…'
40961
- : 'Loading compact history…',
40962
- });
40963
- void (async () => {
40964
- // Include stash commits as graph roots so the toggle's re-fetch
40965
- // sees the same rich graph the boot loader assembles. Without
40966
- // this, flipping `\` into full mode and back loses the stash
40967
- // anchors that loadRowsWithStashes seeded on boot.
40968
- const stashHashes = await getStashCommitHashes(git).catch(() => []);
40969
- const nextRows = await safe(getLogRows(git, merged, {
40970
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40971
- extraRefs: stashHashes,
40972
- }));
40973
- if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
40974
- return;
40975
- }
40976
- if (!nextRows) {
40977
- dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
40978
- return;
40979
- }
40980
- dispatch({ type: 'replaceRows', rows: nextRows });
40981
- const matched = getCommitRows(nextRows).length;
40982
- setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40983
- dispatch({
40984
- type: 'setStatus',
40985
- value: state.fullGraph
40986
- ? `Showing ${matched} commits across all branches`
40987
- : `Showing ${matched} commits (compact)`,
40988
- kind: 'success',
40989
- });
40990
- })();
40991
- }, [dispatch, git, logArgv, state.fullGraph]);
40992
- const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
40993
- .map((line, index) => (line.startsWith('@@') ? index : -1))
40994
- .filter((index) => index >= 0)), [filePreview]);
40995
- const worktreeDirty = Boolean(context.worktree &&
40996
- (context.worktree.stagedCount + context.worktree.unstagedCount + context.worktree.untrackedCount) > 0);
40997
- useInput((inputValue, key) => {
40998
- // First-launch onboarding (P1.3): any keystroke dismisses the overlay
40999
- // and writes the seen-marker. Swallow the keystroke so the same key
41000
- // doesn't also trigger normal input dispatch.
41001
- if (showOnboarding) {
41002
- setShowOnboarding(false);
41003
- markOnboardingSeen();
41004
- return;
41005
- }
41006
- // P4.5: navigation in branches/tags/stash uses the FILTERED list
41007
- // length when a filter is active so j/k stay live instead of getting
41008
- // stuck against a full-list count that no longer matches what's on
41009
- // screen. The filtered lists are memoized at LogInkApp scope (#808
41010
- // perf pass) — reading them here is O(1) instead of O(branches +
41011
- // tags + stashes + worktrees) per keystroke.
41012
- const branchVisibleCount = filteredBranchList.length;
41013
- const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
41014
- const tagVisibleCount = filteredTagList.length;
41015
- const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
41016
- const stashVisibleCount = filteredStashList.length;
41017
- const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
41018
- const reflogVisibleCount = filteredReflogList.length;
41019
- const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
41020
- const submoduleVisibleCount = filteredSubmoduleList.length;
41021
- filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
41022
- const issueVisibleCount = filteredIssueList.length;
41023
- const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
41024
- const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
41025
- const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
41026
- const worktreeVisibleCount = filteredWorktreeList.length;
41027
- // When the diff view is showing a stash patch, swap the previewLineCount
41028
- // to the stash diff length so the existing pageDetailPreview path
41029
- // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
41030
- const diffPreviewLineCount = state.diffSource === 'stash'
41031
- ? stashDiffLines?.length
41032
- : filePreview?.hunks.length;
41033
- // Per-file segmentation for stash diffs reads the LogInkApp-scoped
41034
- // memo so navigation keys + the input-context derivation share a
41035
- // single parse pass per stash patch instead of re-walking the
41036
- // entire patch text on every keystroke.
41037
- const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
41038
- const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
41039
- const stashDiffSelectedPath = state.diffSource === 'stash'
41040
- ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
41041
- : undefined;
41042
- getLogInkInputEvents(state, inputValue, key, {
41043
- detailFileCount: detail?.files.length,
41044
- previewLineCount: diffPreviewLineCount,
41045
- worktreeDiffLineCount: worktreeDiff?.lines.length,
41046
- worktreeFileCount: visibleWorktreeFilesGrouped.length,
41047
- worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
41048
- commitDiffHunkOffsets,
41049
- branchCount: branchVisibleCount,
41050
- branchSelectedShortName,
41051
- tagCount: tagVisibleCount,
41052
- tagSelectedName,
41053
- stashCount: stashVisibleCount,
41054
- reflogCount: reflogVisibleCount,
41055
- reflogSelectedHash,
41056
- submoduleCount: submoduleVisibleCount,
41057
- issueCount: issueVisibleCount,
41058
- issueSelectedUrl,
41059
- pullRequestTriageCount: pullRequestTriageVisibleCount,
41060
- pullRequestTriageSelectedUrl,
41061
- stashSelectedRef,
41062
- stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
41063
- stashDiffSelectedPath,
41064
- worktreeListCount: worktreeVisibleCount,
41065
- worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
41066
- statusGroups: visibleWorktreeGroups.map((group) => ({
41067
- state: group.state,
41068
- count: group.files.length,
41069
- startIndex: group.startIndex,
41070
- })),
41071
- inspectorActionCount: getInspectorActionsForState(state).length,
41072
- commitDiffSelectedPath: state.diffSource === 'commit'
41073
- ? selectedDetailFile?.path
41074
- : undefined,
41075
- commitDiffSelectedSha: state.diffSource === 'commit'
41076
- ? selected?.hash
41077
- : undefined,
41078
- // #931 PR 3b — Submodule drill-in target for the cursored file
41079
- // in a commit diff. Resolved per-render so the Enter handler in
41080
- // `inkInput.ts` doesn't have to re-walk the submodule overview;
41081
- // undefined whenever the cursored file isn't a registered
41082
- // submodule (or the overview / repo root haven't loaded yet).
41083
- commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
41084
- ? resolveCommitDiffDrillInTarget({
41085
- selectedFile: {
41086
- path: selectedDetailFile.path,
41087
- submoduleChange: filePreview?.path === selectedDetailFile.path
41088
- ? filePreview.submoduleChange
41089
- : undefined,
41090
- },
41091
- submodules: context.submodules,
41092
- activeRepoRoot,
41093
- })
41094
- : undefined,
41095
- // #931 PR 4 / #932 — Submodule drill-in target for the cursored
41096
- // row in the dedicated submodules view. Resolved per-render so
41097
- // the Enter handler in `inkInput.ts` doesn't have to re-walk the
41098
- // submodule overview. Gated on `activeView === 'submodules'` so
41099
- // a stale resolution from a different view can't accidentally
41100
- // fire — the runtime only ever populates it when the user is
41101
- // actually on the view.
41102
- submoduleViewDrillIn: state.activeView === 'submodules'
41103
- ? resolveSubmoduleViewDrillInTarget({
41104
- selectedIndex: state.selectedSubmoduleIndex,
41105
- submodules: context.submodules,
41106
- activeRepoRoot,
41107
- })
41108
- : undefined,
41109
- worktreeDirty,
41110
- conflictFileCount: context.operation?.conflictedFiles.length,
41111
- conflictSelectedPath: (() => {
41112
- const files = context.operation?.conflictedFiles;
41113
- if (!files || files.length === 0)
41114
- return undefined;
41115
- const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
41116
- return files[clamped]?.path;
41117
- })(),
41118
- // H / gH need the actual diff text (not just hunk offsets) to
41119
- // slice the cursored hunk into a `git apply` patch. Stash uses
41120
- // the full `git stash show -p` output; commit-diff uses the
41121
- // per-file `filePreview.hunks` array. Either way, extractDiffHunk
41122
- // walks `@@` headers and synthesizes a fresh diff --git / --- /
41123
- // +++ header set using the path the caller already resolved.
41124
- diffLinesForHunkApply: state.diffSource === 'stash'
41125
- ? stashDiffLines
41126
- : state.diffSource === 'commit'
41127
- ? filePreview?.hunks
41128
- : undefined,
41129
- // Line count of the changelog text, used by the changelog view's
41130
- // j/k/PgUp/PgDn scroll bindings to clamp `pageChangelog` deltas.
41131
- // Computed from view state rather than threaded through context
41132
- // because the surface owns its own content — no external loader.
41133
- changelogLineCount: state.changelogView.text?.split('\n').length,
41134
- // Approximate line count for the split-plan overlay. Each group
41135
- // renders as a header + (body if any) + files block + (rationale
41136
- // if any) + blank separator. Used by j/k/PgUp/PgDn to clamp the
41137
- // scroll offset. The exact render math lives in the overlay
41138
- // module — this is a close-enough heuristic for clamping.
41139
- // #879 item 3 — short sha of the bisect terminator (if any).
41140
- // Gates `y`/`Y` yank on the completion panel and lets the
41141
- // runtime resolve the value without re-parsing the log.
41142
- bisectCompletionSha: context.bisect?.active
41143
- ? getBisectCompletion(context.bisect.log)?.sha
41144
- : undefined,
41145
- // #879 item 4 — disambiguates the bisect view's `s` keystroke
41146
- // (skip current candidate vs. start the wizard).
41147
- bisectActive: Boolean(context.bisect?.active),
41148
- splitPlanLineCount: state.splitPlan?.plan
41149
- ? state.splitPlan.plan.groups.reduce((sum, group) => {
41150
- let lines = 2; // title + separator
41151
- if (group.body)
41152
- lines += group.body.split('\n').length + 1;
41153
- if (group.rationale)
41154
- lines += 2;
41155
- lines += (group.files?.length || 0) + 1;
41156
- if ((group.hunks?.length || 0) > 0)
41157
- lines += group.hunks.length + 1;
41158
- return sum + lines;
41159
- }, 0)
41160
- : undefined,
41161
- }).forEach((event) => {
41162
- if (event.type === 'exit') {
41163
- exit();
41164
- }
41165
- else if (event.type === 'refreshContext') {
41166
- void refreshContext();
41167
- }
41168
- else if (event.type === 'toggleSelectedFileStage') {
41169
- void toggleSelectedFileStage();
41170
- }
41171
- else if (event.type === 'toggleSelectedHunkStage') {
41172
- void toggleSelectedHunkStage();
41173
- }
41174
- else if (event.type === 'revertSelectedFile') {
41175
- void revertSelectedFile();
41176
- }
41177
- else if (event.type === 'revertSelectedHunk') {
41178
- void revertSelectedHunk();
41179
- }
41180
- else if (event.type === 'createManualCommit') {
41181
- void createCommitFromCompose();
41182
- }
41183
- else if (event.type === 'runAiCommitDraft') {
41184
- void runAiCommitDraft();
41185
- }
41186
- else if (event.type === 'cancelAiCommitDraft') {
41187
- cancelAiCommitDraft();
41188
- }
41189
- else if (event.type === 'startCreatePullRequest') {
41190
- void startCreatePullRequest();
41191
- }
41192
- else if (event.type === 'cancelPullRequestBodyDraft') {
41193
- cancelPullRequestBodyDraft();
41194
- }
41195
- else if (event.type === 'startChangelogView') {
41196
- void startChangelogView();
41197
- }
41198
- else if (event.type === 'regenerateChangelog') {
41199
- regenerateChangelog();
41200
- }
41201
- else if (event.type === 'yankChangelog') {
41202
- yankChangelog();
41203
- }
41204
- else if (event.type === 'openChangelogInEditor') {
41205
- openChangelogInEditor();
41206
- }
41207
- else if (event.type === 'openComposeInEditor') {
41208
- openComposeInEditor();
41209
- }
41210
- else if (event.type === 'startCommitSplit') {
41211
- void startCommitSplit();
41212
- }
41213
- else if (event.type === 'applyCommitSplit') {
41214
- void applyCommitSplit();
41215
- }
41216
- else if (event.type === 'cancelCommitSplit') {
41217
- cancelCommitSplit();
41218
- }
41219
- else if (event.type === 'yankText') {
41220
- void yankText(event.value, event.label);
41221
- }
41222
- else if (event.type === 'runWorkflowAction') {
41223
- void runWorkflowAction(event.id, event.payload);
41224
- }
41225
- else if (event.type === 'openFileInEditor') {
41226
- openInEditor(event.path);
41227
- }
41228
- else if (event.type === 'yankFromActiveView') {
41229
- void yankFromActiveView(event.short);
41230
- }
41231
- else {
41232
- // P4.5: enrich filter-mutating actions with a precomputed
41233
- // selection snapshot so the reducer can preserve the cursor on
41234
- // the same item when it's still in the filtered result, only
41235
- // snapping to result[0] when the previously selected item drops
41236
- // out. The snapshot lives in the action so the reducer never
41237
- // needs context items.
41238
- const enriched = enrichFilterActionWithRectification(event.action, state, context);
41239
- dispatch(enriched);
41240
- }
41241
- });
41242
- });
41243
- // Layout depends on focus (sidebar grows when focused), so it's
41244
- // computed here — after state is in scope but before the render path.
41245
- const layout = getLogInkLayout({
41246
- columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
41247
- rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
41248
- sidebarFocused: state.focus === 'sidebar',
41249
- inspectorFocused: state.focus === 'detail',
41250
- helpOverlayActive: state.showHelp,
41251
- });
41252
- if (layout.tooSmall) {
41253
- return h(Box, {
41254
- flexDirection: 'column',
41255
- height: layout.rows,
41256
- paddingX: 1,
41257
- paddingY: 1,
41258
- }, h(Text, { bold: true }, appLabel), h(Text, undefined, `Terminal too small: ${layout.columns}x${layout.rows}`), h(Text, { dimColor: true }, `Minimum size is ${LOG_INK_MIN_COLUMNS}x${LOG_INK_MIN_ROWS}.`), h(Text, { dimColor: true }, 'Resize the terminal or run plain coco log.'));
41259
- }
41260
- // First-launch onboarding overlay (P1.3) replaces the entire UI for
41261
- // one render — any keystroke dismisses it and persists the seen-marker.
41262
- if (showOnboarding) {
41263
- return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
41264
- }
41265
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled)), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
41266
- }
41267
-
41268
- /**
41269
- * Explicit color-level detection for the Ink TUI (P5.2).
41270
- *
41271
- * Chalk already approximates hex colors when the terminal can't render
41272
- * truecolor — but we want an explicit signal so the catppuccin / gruvbox
41273
- * presets (which use hex) can fall back to the ANSI-named `default` preset
41274
- * cleanly on minimal SSH sessions, instead of relying on chalk's
41275
- * heuristics. Users who set `NO_COLOR` or pick the `monochrome` preset
41276
- * still get the manual override.
41277
- *
41278
- * Levels (matching the chalk taxonomy):
41279
- * - 'mono' → no ANSI escapes at all (NO_COLOR / TERM=dumb)
41280
- * - '16' → standard 16-color ANSI palette
41281
- * - '256' → xterm-256color
41282
- * - 'truecolor' → 24-bit RGB (COLORTERM=truecolor or known terminals)
41283
- */
41284
- function getColorLevel(env = process.env) {
41285
- if (env.NO_COLOR)
41286
- return 'mono';
41287
- switch (env.FORCE_COLOR) {
41288
- case '0':
41289
- return 'mono';
41290
- case '1':
41291
- return '16';
41292
- case '2':
41293
- return '256';
41294
- case '3':
41295
- return 'truecolor';
41296
- }
41297
- const colorterm = env.COLORTERM?.toLowerCase();
41298
- if (colorterm === 'truecolor' || colorterm === '24bit') {
41299
- return 'truecolor';
41300
- }
41301
- // Modern terminal emulators that publicly advertise truecolor support.
41302
- if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
41303
- return 'truecolor';
41304
- }
41305
- if (env.WT_SESSION) {
41306
- return 'truecolor';
41307
- }
41308
- switch (env.TERM_PROGRAM) {
41309
- case 'iTerm.app':
41310
- case 'WezTerm':
41311
- case 'vscode':
41312
- case 'ghostty':
41313
- case 'Hyper':
41314
- return 'truecolor';
41315
- }
41316
- if (env.TERM === 'dumb')
41317
- return 'mono';
41318
- if (env.TERM?.includes('256color'))
41319
- return '256';
41320
- return '16';
41321
- }
41322
- const TRUECOLOR_PRESETS = new Set(['catppuccin', 'gruvbox', 'dracula', 'nord', 'solarized-dark', 'tokyo-night', 'one-dark', 'rose-pine', 'kanagawa', 'everforest', 'monokai', 'synthwave', 'ayu-dark', 'palenight', 'github-dark', 'horizon', 'nightfox', 'carbonfox', 'tokyonight-storm', 'catppuccin-latte', 'solarized-light', 'github-light', 'iceberg', 'material-ocean', 'moonlight', 'poimandres', 'vitesse-dark', 'vesper', 'flexoki', 'mellow']);
41323
- /**
41324
- * `true` when the named preset relies on hex colors that look best under
41325
- * 24-bit RGB. Used by `createLogInkTheme` to decide whether to downgrade
41326
- * to the ANSI-named `default` palette on lower-capability terminals.
41327
- */
41328
- function presetUsesTrueColor(preset) {
41329
- return preset !== undefined && TRUECOLOR_PRESETS.has(preset);
41330
- }
41331
-
41332
- const THEME_PRESET_COLORS = {
41333
- default: {
41334
- accent: 'cyan',
41335
- border: 'gray',
41336
- danger: 'red',
41337
- focusBorder: 'cyan',
41338
- gitAdded: 'green',
41339
- gitDeleted: 'red',
41340
- gitModified: 'yellow',
41341
- info: 'blue',
41342
- muted: 'gray',
41343
- selection: 'cyan',
41344
- success: 'green',
41345
- warning: 'yellow',
41346
- },
41347
- catppuccin: {
41348
- accent: '#89b4fa',
41349
- border: '#585b70',
41350
- danger: '#f38ba8',
41351
- focusBorder: '#89dceb',
41352
- gitAdded: '#a6e3a1',
41353
- gitDeleted: '#f38ba8',
41354
- gitModified: '#f9e2af',
41355
- info: '#89b4fa',
41356
- muted: '#6c7086',
41357
- selection: '#45475a',
41358
- success: '#a6e3a1',
41359
- warning: '#f9e2af',
41360
- },
41361
- gruvbox: {
41362
- accent: '#83a598',
41363
- border: '#665c54',
41364
- danger: '#fb4934',
41365
- focusBorder: '#8ec07c',
41366
- gitAdded: '#b8bb26',
41367
- gitDeleted: '#fb4934',
41368
- gitModified: '#fabd2f',
41369
- info: '#83a598',
41370
- muted: '#928374',
41371
- selection: '#504945',
41372
- success: '#b8bb26',
41373
- warning: '#fabd2f',
41374
- },
41375
- dracula: {
41376
- accent: '#bd93f9',
41377
- border: '#44475a',
41378
- danger: '#ff5555',
41379
- focusBorder: '#ff79c6',
41380
- gitAdded: '#50fa7b',
41381
- gitDeleted: '#ff5555',
41382
- gitModified: '#f1fa8c',
41383
- info: '#8be9fd',
41384
- muted: '#6272a4',
41385
- selection: '#44475a',
41386
- success: '#50fa7b',
41387
- warning: '#f1fa8c',
41388
- },
41389
- nord: {
41390
- accent: '#88c0d0',
41391
- border: '#3b4252',
41392
- danger: '#bf616a',
41393
- focusBorder: '#81a1c1',
41394
- gitAdded: '#a3be8c',
41395
- gitDeleted: '#bf616a',
41396
- gitModified: '#ebcb8b',
41397
- info: '#81a1c1',
41398
- muted: '#4c566a',
41399
- selection: '#3b4252',
41400
- success: '#a3be8c',
41401
- warning: '#ebcb8b',
41402
- },
41403
- 'solarized-dark': {
41404
- accent: '#268bd2',
41405
- border: '#073642',
41406
- danger: '#dc322f',
41407
- focusBorder: '#2aa198',
41408
- gitAdded: '#859900',
41409
- gitDeleted: '#dc322f',
41410
- gitModified: '#b58900',
41411
- info: '#268bd2',
41412
- muted: '#586e75',
41413
- selection: '#073642',
41414
- success: '#859900',
41415
- warning: '#b58900',
41416
- },
41417
- 'tokyo-night': {
41418
- accent: '#7aa2f7',
41419
- border: '#3b4261',
41420
- danger: '#f7768e',
41421
- focusBorder: '#7dcfff',
41422
- gitAdded: '#9ece6a',
41423
- gitDeleted: '#f7768e',
41424
- gitModified: '#e0af68',
41425
- info: '#7aa2f7',
41426
- muted: '#565f89',
41427
- selection: '#33467c',
41428
- success: '#9ece6a',
41429
- warning: '#e0af68',
41430
- },
41431
- 'one-dark': {
41432
- accent: '#61afef',
41433
- border: '#3e4452',
41434
- danger: '#e06c75',
41435
- focusBorder: '#56b6c2',
41436
- gitAdded: '#98c379',
41437
- gitDeleted: '#e06c75',
41438
- gitModified: '#e5c07b',
41439
- info: '#61afef',
41440
- muted: '#5c6370',
41441
- selection: '#3e4452',
41442
- success: '#98c379',
41443
- warning: '#e5c07b',
41444
- },
41445
- 'rose-pine': {
41446
- accent: '#c4a7e7',
41447
- border: '#26233a',
41448
- danger: '#eb6f92',
41449
- focusBorder: '#9ccfd8',
41450
- gitAdded: '#31748f',
41451
- gitDeleted: '#eb6f92',
41452
- gitModified: '#f6c177',
41453
- info: '#9ccfd8',
41454
- muted: '#6e6a86',
41455
- selection: '#2a273f',
41456
- success: '#31748f',
41457
- warning: '#f6c177',
41458
- },
41459
- kanagawa: {
41460
- accent: '#7e9cd8',
41461
- border: '#2a2a37',
41462
- danger: '#e82424',
41463
- focusBorder: '#7fb4ca',
41464
- gitAdded: '#76946a',
41465
- gitDeleted: '#e82424',
41466
- gitModified: '#dca561',
41467
- info: '#7e9cd8',
41468
- muted: '#727169',
41469
- selection: '#2d4f67',
41470
- success: '#76946a',
41471
- warning: '#dca561',
41472
- },
41473
- everforest: {
41474
- accent: '#a7c080',
41475
- border: '#374145',
41476
- danger: '#e67e80',
41477
- focusBorder: '#83c092',
41478
- gitAdded: '#a7c080',
41479
- gitDeleted: '#e67e80',
41480
- gitModified: '#dbbc7f',
41481
- info: '#7fbbb3',
41482
- muted: '#859289',
41483
- selection: '#374145',
41484
- success: '#a7c080',
41485
- warning: '#dbbc7f',
41486
- },
41487
- monokai: {
41488
- accent: '#66d9ef',
41489
- border: '#49483e',
41490
- danger: '#f92672',
41491
- focusBorder: '#a6e22e',
41492
- gitAdded: '#a6e22e',
41493
- gitDeleted: '#f92672',
41494
- gitModified: '#e6db74',
41495
- info: '#66d9ef',
41496
- muted: '#75715e',
41497
- selection: '#49483e',
41498
- success: '#a6e22e',
41499
- warning: '#e6db74',
41500
- },
41501
- synthwave: {
41502
- accent: '#f97e72',
41503
- border: '#34294f',
41504
- danger: '#fe4450',
41505
- focusBorder: '#36f9f6',
41506
- gitAdded: '#72f1b8',
41507
- gitDeleted: '#fe4450',
41508
- gitModified: '#fede5d',
41509
- info: '#36f9f6',
41510
- muted: '#848bbd',
41511
- selection: '#34294f',
41512
- success: '#72f1b8',
41513
- warning: '#fede5d',
41514
- },
41515
- 'ayu-dark': {
41516
- accent: '#e6b450',
41517
- border: '#11151c',
41518
- danger: '#f07178',
41519
- focusBorder: '#39bae6',
41520
- gitAdded: '#7fd962',
41521
- gitDeleted: '#f07178',
41522
- gitModified: '#e6b450',
41523
- info: '#39bae6',
41524
- muted: '#565b66',
41525
- selection: '#1a1f29',
41526
- success: '#7fd962',
41527
- warning: '#e6b450',
41528
- },
41529
- palenight: {
41530
- accent: '#82aaff',
41531
- border: '#3a3f58',
41532
- danger: '#ff5370',
41533
- focusBorder: '#89ddff',
41534
- gitAdded: '#c3e88d',
41535
- gitDeleted: '#ff5370',
41536
- gitModified: '#ffcb6b',
41537
- info: '#82aaff',
41538
- muted: '#676e95',
41539
- selection: '#3a3f58',
41540
- success: '#c3e88d',
41541
- warning: '#ffcb6b',
41542
- },
41543
- 'github-dark': {
41544
- accent: '#58a6ff',
41545
- border: '#30363d',
41546
- danger: '#f85149',
41547
- focusBorder: '#58a6ff',
41548
- gitAdded: '#3fb950',
41549
- gitDeleted: '#f85149',
41550
- gitModified: '#d29922',
41551
- info: '#58a6ff',
41552
- muted: '#8b949e',
41553
- selection: '#264f78',
41554
- success: '#3fb950',
41555
- warning: '#d29922',
41556
- },
41557
- horizon: {
41558
- accent: '#e95678',
41559
- border: '#2e303e',
41560
- danger: '#e95678',
41561
- focusBorder: '#25b0bc',
41562
- gitAdded: '#09f7a0',
41563
- gitDeleted: '#e95678',
41564
- gitModified: '#fab795',
41565
- info: '#25b0bc',
41566
- muted: '#6c6f93',
41567
- selection: '#2e303e',
41568
- success: '#09f7a0',
41569
- warning: '#fab795',
41570
- },
41571
- nightfox: {
41572
- accent: '#719cd6',
41573
- border: '#2b3b51',
41574
- danger: '#c94f6d',
41575
- focusBorder: '#63cdcf',
41576
- gitAdded: '#81b29a',
41577
- gitDeleted: '#c94f6d',
41578
- gitModified: '#dbc074',
41579
- info: '#719cd6',
41580
- muted: '#738091',
41581
- selection: '#2b3b51',
41582
- success: '#81b29a',
41583
- warning: '#dbc074',
41584
- },
41585
- carbonfox: {
41586
- accent: '#78a9ff',
41587
- border: '#353535',
41588
- danger: '#ee5396',
41589
- focusBorder: '#33b1ff',
41590
- gitAdded: '#42be65',
41591
- gitDeleted: '#ee5396',
41592
- gitModified: '#ffe97b',
41593
- info: '#78a9ff',
41594
- muted: '#7b7c7e',
41595
- selection: '#353535',
41596
- success: '#42be65',
41597
- warning: '#ffe97b',
41598
- },
41599
- 'tokyonight-storm': {
41600
- accent: '#7aa2f7',
41601
- border: '#2f334d',
41602
- danger: '#f7768e',
41603
- focusBorder: '#2ac3de',
41604
- gitAdded: '#9ece6a',
41605
- gitDeleted: '#f7768e',
41606
- gitModified: '#e0af68',
41607
- info: '#2ac3de',
41608
- muted: '#545c7e',
41609
- selection: '#2f334d',
41610
- success: '#9ece6a',
41611
- warning: '#e0af68',
41612
- },
41613
- 'catppuccin-latte': {
41614
- accent: '#1e66f5',
41615
- border: '#ccd0da',
41616
- danger: '#d20f39',
41617
- focusBorder: '#179299',
41618
- gitAdded: '#40a02b',
41619
- gitDeleted: '#d20f39',
41620
- gitModified: '#df8e1d',
41621
- info: '#1e66f5',
41622
- muted: '#9ca0b0',
41623
- selection: '#ccd0da',
41624
- success: '#40a02b',
41625
- warning: '#df8e1d',
41626
- },
41627
- 'solarized-light': {
41628
- accent: '#268bd2',
41629
- border: '#eee8d5',
41630
- danger: '#dc322f',
41631
- focusBorder: '#2aa198',
41632
- gitAdded: '#859900',
41633
- gitDeleted: '#dc322f',
41634
- gitModified: '#b58900',
41635
- info: '#268bd2',
41636
- muted: '#93a1a1',
41637
- selection: '#eee8d5',
41638
- success: '#859900',
41639
- warning: '#b58900',
41640
- },
41641
- 'github-light': {
41642
- accent: '#0969da',
41643
- border: '#d0d7de',
41644
- danger: '#cf222e',
41645
- focusBorder: '#0969da',
41646
- gitAdded: '#1a7f37',
41647
- gitDeleted: '#cf222e',
41648
- gitModified: '#9a6700',
41649
- info: '#0969da',
41650
- muted: '#656d76',
41651
- selection: '#ddf4ff',
41652
- success: '#1a7f37',
41653
- warning: '#9a6700',
41654
- },
41655
- iceberg: {
41656
- accent: '#84a0c6',
41657
- border: '#1e2132',
41658
- danger: '#e27878',
41659
- focusBorder: '#89b8c2',
41660
- gitAdded: '#b4be82',
41661
- gitDeleted: '#e27878',
41662
- gitModified: '#e2a478',
41663
- info: '#84a0c6',
41664
- muted: '#6b7089',
41665
- selection: '#1e2132',
41666
- success: '#b4be82',
41667
- warning: '#e2a478',
41668
- },
41669
- 'material-ocean': {
41670
- accent: '#82aaff',
41671
- border: '#2b2f3a',
41672
- danger: '#f07178',
41673
- focusBorder: '#89ddff',
41674
- gitAdded: '#c3e88d',
41675
- gitDeleted: '#f07178',
41676
- gitModified: '#ffcb6b',
41677
- info: '#82aaff',
41678
- muted: '#464b5d',
41679
- selection: '#2b2f3a',
41680
- success: '#c3e88d',
41681
- warning: '#ffcb6b',
41682
- },
41683
- moonlight: {
41684
- accent: '#82aaff',
41685
- border: '#2f334d',
41686
- danger: '#ff757f',
41687
- focusBorder: '#86e1fc',
41688
- gitAdded: '#c3e88d',
41689
- gitDeleted: '#ff757f',
41690
- gitModified: '#ffc777',
41691
- info: '#82aaff',
41692
- muted: '#636da6',
41693
- selection: '#2f334d',
41694
- success: '#c3e88d',
41695
- warning: '#ffc777',
41696
- },
41697
- poimandres: {
41698
- accent: '#add7ff',
41699
- border: '#1b1e28',
41700
- danger: '#d0679d',
41701
- focusBorder: '#5de4c7',
41702
- gitAdded: '#5de4c7',
41703
- gitDeleted: '#d0679d',
41704
- gitModified: '#fffac2',
41705
- info: '#add7ff',
41706
- muted: '#506477',
41707
- selection: '#1b1e28',
41708
- success: '#5de4c7',
41709
- warning: '#fffac2',
41710
- },
41711
- 'vitesse-dark': {
41712
- accent: '#4d9375',
41713
- border: '#282828',
41714
- danger: '#cb7676',
41715
- focusBorder: '#4d9375',
41716
- gitAdded: '#4d9375',
41717
- gitDeleted: '#cb7676',
41718
- gitModified: '#e6cc77',
41719
- info: '#6394bf',
41720
- muted: '#758575',
41721
- selection: '#282828',
41722
- success: '#4d9375',
41723
- warning: '#e6cc77',
41724
- },
41725
- vesper: {
41726
- accent: '#ffc799',
41727
- border: '#232323',
41728
- danger: '#f5a191',
41729
- focusBorder: '#99ffe4',
41730
- gitAdded: '#99ffe4',
41731
- gitDeleted: '#f5a191',
41732
- gitModified: '#ffc799',
41733
- info: '#a0c4ff',
41734
- muted: '#575757',
41735
- selection: '#232323',
41736
- success: '#99ffe4',
41737
- warning: '#ffc799',
41738
- },
41739
- flexoki: {
41740
- accent: '#205ea6',
41741
- border: '#343331',
41742
- danger: '#af3029',
41743
- focusBorder: '#24837b',
41744
- gitAdded: '#66800b',
41745
- gitDeleted: '#af3029',
41746
- gitModified: '#ad8301',
41747
- info: '#205ea6',
41748
- muted: '#878580',
41749
- selection: '#343331',
41750
- success: '#66800b',
41751
- warning: '#ad8301',
41752
- },
41753
- mellow: {
41754
- accent: '#7eb8da',
41755
- border: '#2a2a2a',
41756
- danger: '#f5a191',
41757
- focusBorder: '#a3d4a0',
41758
- gitAdded: '#a3d4a0',
41759
- gitDeleted: '#f5a191',
41760
- gitModified: '#f0c674',
41761
- info: '#7eb8da',
41762
- muted: '#6b6b6b',
41763
- selection: '#2a2a2a',
41764
- success: '#a3d4a0',
41765
- warning: '#f0c674',
41766
- },
41767
- };
41768
- function shouldUseAscii(term) {
41769
- if (!term) {
41770
- return false;
41771
- }
41772
- return term === 'dumb' || term.startsWith('vt100');
41773
- }
41774
- function createLogInkTheme(options = {}) {
41775
- const noColor = (options.noColor ?? Boolean(process.env.NO_COLOR)) ||
41776
- options.preset === 'monochrome';
41777
- const ascii = options.ascii ?? shouldUseAscii(options.term ?? process.env.TERM);
41778
- const requestedPreset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
41779
- // P5.2 — gracefully downgrade hex presets (catppuccin / gruvbox) when
41780
- // the host terminal can't render truecolor. Chalk approximates hex in
41781
- // those modes anyway, but the default preset's ANSI-named palette
41782
- // renders far more faithfully on 16-color terminals.
41783
- const colorLevel = getColorLevel(options.env ?? process.env);
41784
- const preset = !noColor && presetUsesTrueColor(requestedPreset) && colorLevel !== 'truecolor'
41785
- ? 'default'
41786
- : requestedPreset;
41787
- const colors = noColor
41788
- ? {}
41789
- : {
41790
- ...THEME_PRESET_COLORS[preset],
41791
- ...options.colors,
42108
+ dispatch({
42109
+ type: 'setStatus',
42110
+ value: `${target.label} target commit returned no rows — orphan ref?`,
42111
+ kind: 'warning',
42112
+ });
42113
+ }
42114
+ }
42115
+ catch (error) {
42116
+ if (mountedRef.current) {
42117
+ dispatch({
42118
+ type: 'setStatus',
42119
+ value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
42120
+ kind: 'error',
42121
+ });
42122
+ }
42123
+ }
42124
+ }, [dispatch, git]);
42125
+ React.useEffect(() => {
42126
+ loadCommitContextRef.current = loadCommitContext;
42127
+ }, [loadCommitContext]);
42128
+ // Server-side history filter (#776). When the user submits `path:foo`
42129
+ // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
42130
+ // this effect picks up the change, re-runs `getLogRows` with merged
42131
+ // args, and replaces the rows. Clearing the fetch args (Ctrl+U inside
42132
+ // filter mode) re-fetches with the original logArgv so the user gets
42133
+ // the live full log back, not a stale snapshot of the initial rows.
42134
+ const historyFetchEffectInitialized = React.useRef(false);
42135
+ const historyFetchRequestRef = React.useRef(0);
42136
+ React.useEffect(() => {
42137
+ if (!logArgv)
42138
+ return;
42139
+ // Skip the first run — initial rows came in via deps.rows; we only
42140
+ // want to fetch in response to *changes* to historyFetchArgs.
42141
+ if (!historyFetchEffectInitialized.current) {
42142
+ historyFetchEffectInitialized.current = true;
42143
+ return;
42144
+ }
42145
+ const requestId = historyFetchRequestRef.current + 1;
42146
+ historyFetchRequestRef.current = requestId;
42147
+ const fetchArgs = state.historyFetchArgs;
42148
+ const merged = {
42149
+ ...logArgv,
42150
+ ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
42151
+ ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
41792
42152
  };
41793
- return {
41794
- noColor,
41795
- ascii,
41796
- borderStyle: options.borderStyle || (ascii ? 'classic' : 'round'),
41797
- colors,
41798
- };
42153
+ const description = fetchArgs?.author
42154
+ ? `author:${fetchArgs.author}`
42155
+ : fetchArgs?.path
42156
+ ? `path:${fetchArgs.path}`
42157
+ : undefined;
42158
+ dispatch({
42159
+ type: 'setStatus',
42160
+ value: description ? `Refetching with ${description}` : 'Restoring full log',
42161
+ });
42162
+ void (async () => {
42163
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
42164
+ const nextRows = await safe(getLogRows(git, merged, {
42165
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
42166
+ extraRefs: stashHashes,
42167
+ }));
42168
+ if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
42169
+ return;
42170
+ }
42171
+ if (!nextRows) {
42172
+ dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
42173
+ return;
42174
+ }
42175
+ dispatch({ type: 'replaceRows', rows: nextRows });
42176
+ const matched = getCommitRows(nextRows).length;
42177
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
42178
+ dispatch({
42179
+ type: 'setStatus',
42180
+ value: description
42181
+ ? `Showing ${matched} commits matching ${description}`
42182
+ : 'Showing full log',
42183
+ kind: 'success',
42184
+ });
42185
+ })();
42186
+ }, [dispatch, git, logArgv, state.historyFetchArgs]);
42187
+ // Graph mode toggle (`g` key, #791 follow-up). The header label flips
42188
+ // between "compact graph" and "full graph", but unless we re-fetch with
42189
+ // the right `view`, the underlying rows still come from the user's
42190
+ // initial argv (default `--first-parent --no-merges`) and the renderer
42191
+ // has no topology to draw — defeating the per-lane / junction work.
42192
+ // Mirrors the historyFetchArgs effect: skip first run, request-id ref
42193
+ // for stale-completion guard, swap rows in place via replaceRows.
42194
+ const toggleGraphEffectInitialized = React.useRef(false);
42195
+ const toggleGraphRequestRef = React.useRef(0);
42196
+ React.useEffect(() => {
42197
+ if (!logArgv)
42198
+ return;
42199
+ if (!toggleGraphEffectInitialized.current) {
42200
+ toggleGraphEffectInitialized.current = true;
42201
+ return;
42202
+ }
42203
+ const requestId = toggleGraphRequestRef.current + 1;
42204
+ toggleGraphRequestRef.current = requestId;
42205
+ const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
42206
+ dispatch({
42207
+ type: 'setStatus',
42208
+ value: state.fullGraph
42209
+ ? 'Loading full topology…'
42210
+ : 'Loading compact history…',
42211
+ });
42212
+ void (async () => {
42213
+ // Include stash commits as graph roots so the toggle's re-fetch
42214
+ // sees the same rich graph the boot loader assembles. Without
42215
+ // this, flipping `\` into full mode and back loses the stash
42216
+ // anchors that loadRowsWithStashes seeded on boot.
42217
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
42218
+ const nextRows = await safe(getLogRows(git, merged, {
42219
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
42220
+ extraRefs: stashHashes,
42221
+ }));
42222
+ if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
42223
+ return;
42224
+ }
42225
+ if (!nextRows) {
42226
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
42227
+ return;
42228
+ }
42229
+ dispatch({ type: 'replaceRows', rows: nextRows });
42230
+ const matched = getCommitRows(nextRows).length;
42231
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
42232
+ dispatch({
42233
+ type: 'setStatus',
42234
+ value: state.fullGraph
42235
+ ? `Showing ${matched} commits across all branches`
42236
+ : `Showing ${matched} commits (compact)`,
42237
+ kind: 'success',
42238
+ });
42239
+ })();
42240
+ }, [dispatch, git, logArgv, state.fullGraph]);
42241
+ const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
42242
+ .map((line, index) => (line.startsWith('@@') ? index : -1))
42243
+ .filter((index) => index >= 0)), [filePreview]);
42244
+ const worktreeDirty = Boolean(context.worktree &&
42245
+ (context.worktree.stagedCount + context.worktree.unstagedCount + context.worktree.untrackedCount) > 0);
42246
+ useInput((inputValue, key) => {
42247
+ // First-launch onboarding (P1.3): any keystroke dismisses the overlay
42248
+ // and writes the seen-marker. Swallow the keystroke so the same key
42249
+ // doesn't also trigger normal input dispatch.
42250
+ if (showOnboarding) {
42251
+ setShowOnboarding(false);
42252
+ markOnboardingSeen();
42253
+ return;
42254
+ }
42255
+ // P4.5: navigation in branches/tags/stash uses the FILTERED list
42256
+ // length when a filter is active so j/k stay live instead of getting
42257
+ // stuck against a full-list count that no longer matches what's on
42258
+ // screen. The filtered lists are memoized at LogInkApp scope (#808
42259
+ // perf pass) — reading them here is O(1) instead of O(branches +
42260
+ // tags + stashes + worktrees) per keystroke.
42261
+ const branchVisibleCount = filteredBranchList.length;
42262
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
42263
+ const tagVisibleCount = filteredTagList.length;
42264
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
42265
+ const stashVisibleCount = filteredStashList.length;
42266
+ const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
42267
+ const reflogVisibleCount = filteredReflogList.length;
42268
+ const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
42269
+ const submoduleVisibleCount = filteredSubmoduleList.length;
42270
+ filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
42271
+ const issueVisibleCount = filteredIssueList.length;
42272
+ const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
42273
+ const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
42274
+ const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
42275
+ const worktreeVisibleCount = filteredWorktreeList.length;
42276
+ // When the diff view is showing a stash patch, swap the previewLineCount
42277
+ // to the stash diff length so the existing pageDetailPreview path
42278
+ // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
42279
+ const diffPreviewLineCount = state.diffSource === 'stash'
42280
+ ? stashDiffLines?.length
42281
+ : filePreview?.hunks.length;
42282
+ // Per-file segmentation for stash diffs reads the LogInkApp-scoped
42283
+ // memo so navigation keys + the input-context derivation share a
42284
+ // single parse pass per stash patch instead of re-walking the
42285
+ // entire patch text on every keystroke.
42286
+ const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
42287
+ const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
42288
+ const stashDiffSelectedPath = state.diffSource === 'stash'
42289
+ ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
42290
+ : undefined;
42291
+ getLogInkInputEvents(state, inputValue, key, {
42292
+ detailFileCount: detail?.files.length,
42293
+ previewLineCount: diffPreviewLineCount,
42294
+ worktreeDiffLineCount: worktreeDiff?.lines.length,
42295
+ worktreeFileCount: visibleWorktreeFilesGrouped.length,
42296
+ worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
42297
+ commitDiffHunkOffsets,
42298
+ branchCount: branchVisibleCount,
42299
+ branchSelectedShortName,
42300
+ tagCount: tagVisibleCount,
42301
+ tagSelectedName,
42302
+ stashCount: stashVisibleCount,
42303
+ reflogCount: reflogVisibleCount,
42304
+ reflogSelectedHash,
42305
+ submoduleCount: submoduleVisibleCount,
42306
+ issueCount: issueVisibleCount,
42307
+ issueSelectedUrl,
42308
+ pullRequestTriageCount: pullRequestTriageVisibleCount,
42309
+ pullRequestTriageSelectedUrl,
42310
+ stashSelectedRef,
42311
+ stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
42312
+ stashDiffSelectedPath,
42313
+ worktreeListCount: worktreeVisibleCount,
42314
+ worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
42315
+ statusGroups: visibleWorktreeGroups.map((group) => ({
42316
+ state: group.state,
42317
+ count: group.files.length,
42318
+ startIndex: group.startIndex,
42319
+ })),
42320
+ inspectorActionCount: getInspectorActionsForState(state).length,
42321
+ commitDiffSelectedPath: state.diffSource === 'commit'
42322
+ ? selectedDetailFile?.path
42323
+ : undefined,
42324
+ commitDiffSelectedSha: state.diffSource === 'commit'
42325
+ ? selected?.hash
42326
+ : undefined,
42327
+ // #931 PR 3b — Submodule drill-in target for the cursored file
42328
+ // in a commit diff. Resolved per-render so the Enter handler in
42329
+ // `inkInput.ts` doesn't have to re-walk the submodule overview;
42330
+ // undefined whenever the cursored file isn't a registered
42331
+ // submodule (or the overview / repo root haven't loaded yet).
42332
+ commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
42333
+ ? resolveCommitDiffDrillInTarget({
42334
+ selectedFile: {
42335
+ path: selectedDetailFile.path,
42336
+ submoduleChange: filePreview?.path === selectedDetailFile.path
42337
+ ? filePreview.submoduleChange
42338
+ : undefined,
42339
+ },
42340
+ submodules: context.submodules,
42341
+ activeRepoRoot,
42342
+ })
42343
+ : undefined,
42344
+ // #931 PR 4 / #932 — Submodule drill-in target for the cursored
42345
+ // row in the dedicated submodules view. Resolved per-render so
42346
+ // the Enter handler in `inkInput.ts` doesn't have to re-walk the
42347
+ // submodule overview. Gated on `activeView === 'submodules'` so
42348
+ // a stale resolution from a different view can't accidentally
42349
+ // fire — the runtime only ever populates it when the user is
42350
+ // actually on the view.
42351
+ submoduleViewDrillIn: state.activeView === 'submodules'
42352
+ ? resolveSubmoduleViewDrillInTarget({
42353
+ selectedIndex: state.selectedSubmoduleIndex,
42354
+ submodules: context.submodules,
42355
+ activeRepoRoot,
42356
+ })
42357
+ : undefined,
42358
+ worktreeDirty,
42359
+ conflictFileCount: context.operation?.conflictedFiles.length,
42360
+ conflictSelectedPath: (() => {
42361
+ const files = context.operation?.conflictedFiles;
42362
+ if (!files || files.length === 0)
42363
+ return undefined;
42364
+ const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
42365
+ return files[clamped]?.path;
42366
+ })(),
42367
+ // H / gH need the actual diff text (not just hunk offsets) to
42368
+ // slice the cursored hunk into a `git apply` patch. Stash uses
42369
+ // the full `git stash show -p` output; commit-diff uses the
42370
+ // per-file `filePreview.hunks` array. Either way, extractDiffHunk
42371
+ // walks `@@` headers and synthesizes a fresh diff --git / --- /
42372
+ // +++ header set using the path the caller already resolved.
42373
+ diffLinesForHunkApply: state.diffSource === 'stash'
42374
+ ? stashDiffLines
42375
+ : state.diffSource === 'commit'
42376
+ ? filePreview?.hunks
42377
+ : undefined,
42378
+ // Line count of the changelog text, used by the changelog view's
42379
+ // j/k/PgUp/PgDn scroll bindings to clamp `pageChangelog` deltas.
42380
+ // Computed from view state rather than threaded through context
42381
+ // because the surface owns its own content — no external loader.
42382
+ changelogLineCount: state.changelogView.text?.split('\n').length,
42383
+ // Approximate line count for the split-plan overlay. Each group
42384
+ // renders as a header + (body if any) + files block + (rationale
42385
+ // if any) + blank separator. Used by j/k/PgUp/PgDn to clamp the
42386
+ // scroll offset. The exact render math lives in the overlay
42387
+ // module — this is a close-enough heuristic for clamping.
42388
+ // #879 item 3 — short sha of the bisect terminator (if any).
42389
+ // Gates `y`/`Y` yank on the completion panel and lets the
42390
+ // runtime resolve the value without re-parsing the log.
42391
+ bisectCompletionSha: context.bisect?.active
42392
+ ? getBisectCompletion(context.bisect.log)?.sha
42393
+ : undefined,
42394
+ // #879 item 4 — disambiguates the bisect view's `s` keystroke
42395
+ // (skip current candidate vs. start the wizard).
42396
+ bisectActive: Boolean(context.bisect?.active),
42397
+ splitPlanLineCount: state.splitPlan?.plan
42398
+ ? state.splitPlan.plan.groups.reduce((sum, group) => {
42399
+ let lines = 2; // title + separator
42400
+ if (group.body)
42401
+ lines += group.body.split('\n').length + 1;
42402
+ if (group.rationale)
42403
+ lines += 2;
42404
+ lines += (group.files?.length || 0) + 1;
42405
+ const hunkCount = group.hunks?.length || 0;
42406
+ if (hunkCount > 0)
42407
+ lines += hunkCount + 1;
42408
+ return sum + lines;
42409
+ }, 0)
42410
+ : undefined,
42411
+ }).forEach((event) => {
42412
+ if (event.type === 'exit') {
42413
+ exit();
42414
+ }
42415
+ else if (event.type === 'refreshContext') {
42416
+ void refreshContext();
42417
+ }
42418
+ else if (event.type === 'toggleSelectedFileStage') {
42419
+ void toggleSelectedFileStage();
42420
+ }
42421
+ else if (event.type === 'toggleSelectedHunkStage') {
42422
+ void toggleSelectedHunkStage();
42423
+ }
42424
+ else if (event.type === 'revertSelectedFile') {
42425
+ void revertSelectedFile();
42426
+ }
42427
+ else if (event.type === 'revertSelectedHunk') {
42428
+ void revertSelectedHunk();
42429
+ }
42430
+ else if (event.type === 'createManualCommit') {
42431
+ void createCommitFromCompose();
42432
+ }
42433
+ else if (event.type === 'runAiCommitDraft') {
42434
+ void runAiCommitDraft();
42435
+ }
42436
+ else if (event.type === 'cancelAiCommitDraft') {
42437
+ cancelAiCommitDraft();
42438
+ }
42439
+ else if (event.type === 'startCreatePullRequest') {
42440
+ void startCreatePullRequest();
42441
+ }
42442
+ else if (event.type === 'cancelPullRequestBodyDraft') {
42443
+ cancelPullRequestBodyDraft();
42444
+ }
42445
+ else if (event.type === 'startChangelogView') {
42446
+ void startChangelogView();
42447
+ }
42448
+ else if (event.type === 'regenerateChangelog') {
42449
+ regenerateChangelog();
42450
+ }
42451
+ else if (event.type === 'yankChangelog') {
42452
+ yankChangelog();
42453
+ }
42454
+ else if (event.type === 'openChangelogInEditor') {
42455
+ openChangelogInEditor();
42456
+ }
42457
+ else if (event.type === 'openComposeInEditor') {
42458
+ openComposeInEditor();
42459
+ }
42460
+ else if (event.type === 'startCommitSplit') {
42461
+ void startCommitSplit();
42462
+ }
42463
+ else if (event.type === 'applyCommitSplit') {
42464
+ void applyCommitSplit();
42465
+ }
42466
+ else if (event.type === 'cancelCommitSplit') {
42467
+ cancelCommitSplit();
42468
+ }
42469
+ else if (event.type === 'yankText') {
42470
+ void yankText(event.value, event.label);
42471
+ }
42472
+ else if (event.type === 'runWorkflowAction') {
42473
+ void runWorkflowAction(event.id, event.payload);
42474
+ }
42475
+ else if (event.type === 'openFileInEditor') {
42476
+ openInEditor(event.path);
42477
+ }
42478
+ else if (event.type === 'yankFromActiveView') {
42479
+ void yankFromActiveView(event.short);
42480
+ }
42481
+ else if (event.type === 'applyThemePreset') {
42482
+ // Apply for the session immediately, and best-effort persist to the
42483
+ // global config so it sticks across launches. The picker has already
42484
+ // dispatched `toggleThemePicker` (closing it), which clears the
42485
+ // preview via the sync effect below — the session preset takes over.
42486
+ const preset = event.preset;
42487
+ setThemeSessionPreset(preset);
42488
+ saveThemePreset(preset);
42489
+ }
42490
+ else {
42491
+ // P4.5: enrich filter-mutating actions with a precomputed
42492
+ // selection snapshot so the reducer can preserve the cursor on
42493
+ // the same item when it's still in the filtered result, only
42494
+ // snapping to result[0] when the previously selected item drops
42495
+ // out. The snapshot lives in the action so the reducer never
42496
+ // needs context items.
42497
+ const enriched = enrichFilterActionWithRectification(event.action, state, context);
42498
+ dispatch(enriched);
42499
+ }
42500
+ });
42501
+ });
42502
+ // Layout depends on focus (sidebar grows when focused), so it's
42503
+ // computed here — after state is in scope but before the render path.
42504
+ const layout = getLogInkLayout({
42505
+ columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
42506
+ rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
42507
+ sidebarFocused: state.focus === 'sidebar',
42508
+ inspectorFocused: state.focus === 'detail',
42509
+ helpOverlayActive: state.showHelp,
42510
+ });
42511
+ if (layout.tooSmall) {
42512
+ return h(Box, {
42513
+ flexDirection: 'column',
42514
+ height: layout.rows,
42515
+ paddingX: 1,
42516
+ paddingY: 1,
42517
+ }, h(Text, { bold: true }, appLabel), h(Text, undefined, `Terminal too small: ${layout.columns}x${layout.rows}`), h(Text, { dimColor: true }, `Minimum size is ${LOG_INK_MIN_COLUMNS}x${LOG_INK_MIN_ROWS}.`), h(Text, { dimColor: true }, 'Resize the terminal or run plain coco log.'));
42518
+ }
42519
+ // First-launch onboarding overlay (P1.3) replaces the entire UI for
42520
+ // one render — any keystroke dismisses it and persists the seen-marker.
42521
+ if (showOnboarding) {
42522
+ return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
42523
+ }
42524
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled)), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
41799
42525
  }
41800
42526
 
41801
42527
  /**
@@ -41973,6 +42699,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
41973
42699
  React,
41974
42700
  rows,
41975
42701
  theme: createLogInkTheme(options.theme),
42702
+ themeConfig: options.theme,
41976
42703
  resumeRef,
41977
42704
  });
41978
42705
  const instance = ink.render(app, getLogInkRenderOptions({ input, output, error }));
@@ -43418,7 +44145,7 @@ const options$1 = {
43418
44145
  },
43419
44146
  theme: {
43420
44147
  description: 'TUI theme preset',
43421
- choices: ['default', 'monochrome', 'catppuccin', 'gruvbox', 'dracula', 'nord', 'solarized-dark', 'tokyo-night', 'one-dark', 'rose-pine', 'kanagawa', 'everforest', 'monokai', 'synthwave', 'ayu-dark', 'palenight', 'github-dark', 'horizon', 'nightfox', 'carbonfox', 'tokyonight-storm', 'catppuccin-latte', 'solarized-light', 'github-light', 'iceberg', 'material-ocean', 'moonlight', 'poimandres', 'vitesse-dark', 'vesper', 'flexoki', 'mellow'],
44148
+ choices: ['default', 'monochrome', 'catppuccin', 'gruvbox', 'dracula', 'nord', 'solarized-dark', 'tokyo-night', 'one-dark', 'rose-pine', 'kanagawa', 'everforest', 'monokai', 'synthwave', 'ayu-dark', 'palenight', 'github-dark', 'horizon', 'nightfox', 'carbonfox', 'tokyonight-storm', 'catppuccin-latte', 'solarized-light', 'github-light', 'iceberg', 'material-ocean', 'moonlight', 'poimandres', 'vitesse-dark', 'vesper', 'flexoki', 'mellow', 'night-owl', 'cobalt2', 'oceanic-next', 'catppuccin-macchiato', 'gruvbox-light', 'tokyo-night-day', 'one-light', 'ayu-light', 'rose-pine-dawn', 'everforest-light', 'vitesse-light', 'dayfox', 'night-owl-light', 'flexoki-light', 'material-lighter', 'papercolor-light', 'modus-operandi', 'quiet-light'],
43422
44149
  },
43423
44150
  };
43424
44151
  const builder$1 = (yargs) => {
@@ -43446,7 +44173,7 @@ const options = {
43446
44173
  },
43447
44174
  theme: {
43448
44175
  description: 'TUI theme preset',
43449
- choices: ['default', 'monochrome', 'catppuccin', 'gruvbox', 'dracula', 'nord', 'solarized-dark', 'tokyo-night', 'one-dark', 'rose-pine', 'kanagawa', 'everforest', 'monokai', 'synthwave', 'ayu-dark', 'palenight', 'github-dark', 'horizon', 'nightfox', 'carbonfox', 'tokyonight-storm', 'catppuccin-latte', 'solarized-light', 'github-light', 'iceberg', 'material-ocean', 'moonlight', 'poimandres', 'vitesse-dark', 'vesper', 'flexoki', 'mellow'],
44176
+ choices: ['default', 'monochrome', 'catppuccin', 'gruvbox', 'dracula', 'nord', 'solarized-dark', 'tokyo-night', 'one-dark', 'rose-pine', 'kanagawa', 'everforest', 'monokai', 'synthwave', 'ayu-dark', 'palenight', 'github-dark', 'horizon', 'nightfox', 'carbonfox', 'tokyonight-storm', 'catppuccin-latte', 'solarized-light', 'github-light', 'iceberg', 'material-ocean', 'moonlight', 'poimandres', 'vitesse-dark', 'vesper', 'flexoki', 'mellow', 'night-owl', 'cobalt2', 'oceanic-next', 'catppuccin-macchiato', 'gruvbox-light', 'tokyo-night-day', 'one-light', 'ayu-light', 'rose-pine-dawn', 'everforest-light', 'vitesse-light', 'dayfox', 'night-owl-light', 'flexoki-light', 'material-lighter', 'papercolor-light', 'modus-operandi', 'quiet-light'],
43450
44177
  },
43451
44178
  };
43452
44179
  const builder = (yargs) => {
@@ -44149,6 +44876,9 @@ function createWorkspaceState(init) {
44149
44876
  roots: init.roots,
44150
44877
  showHelp: false,
44151
44878
  showOnboarding: Boolean(init.showOnboarding),
44879
+ showThemePicker: false,
44880
+ themePickerFilter: '',
44881
+ themePickerIndex: 0,
44152
44882
  knownRepoPaths: init.knownRepoPaths ?? [],
44153
44883
  pullRequestFetching: [],
44154
44884
  };
@@ -44319,6 +45049,39 @@ function applyWorkspaceAction(state, action) {
44319
45049
  case 'close-help': {
44320
45050
  return { ...state, showHelp: false };
44321
45051
  }
45052
+ case 'toggle-theme-picker': {
45053
+ return {
45054
+ ...state,
45055
+ showThemePicker: !state.showThemePicker,
45056
+ showHelp: false,
45057
+ showOnboarding: false,
45058
+ themePickerFilter: '',
45059
+ themePickerIndex: 0,
45060
+ };
45061
+ }
45062
+ case 'move-theme-picker': {
45063
+ return {
45064
+ ...state,
45065
+ themePickerIndex: clampCursor(state.themePickerIndex + action.delta, action.presetCount),
45066
+ };
45067
+ }
45068
+ case 'append-theme-picker-filter': {
45069
+ return {
45070
+ ...state,
45071
+ themePickerFilter: `${state.themePickerFilter}${action.value}`,
45072
+ themePickerIndex: 0,
45073
+ };
45074
+ }
45075
+ case 'backspace-theme-picker-filter': {
45076
+ return {
45077
+ ...state,
45078
+ themePickerFilter: state.themePickerFilter.slice(0, -1),
45079
+ themePickerIndex: 0,
45080
+ };
45081
+ }
45082
+ case 'clear-theme-picker-filter': {
45083
+ return { ...state, themePickerFilter: '', themePickerIndex: 0 };
45084
+ }
44322
45085
  case 'dismiss-onboarding': {
44323
45086
  return { ...state, showOnboarding: false };
44324
45087
  }
@@ -44730,33 +45493,48 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
44730
45493
  });
44731
45494
  return chips;
44732
45495
  }
44733
- // Footer hints prioritize discoverable / forgettable actions and
44734
- // drop the bindings users can find on their own (arrow keys, Enter
44735
- // for "open", Tab for "switch panels"). The full keymap lives behind
44736
- // `?` so nothing is hidden, just decluttered.
44737
- const LIST_HINT = 's sort · / filter · r/R refresh · a add · d remove · ? help · q quit';
44738
- const SIDEBAR_HINT = '? help · q quit';
44739
- const FILTER_HINT = 'type to filter · enter apply · esc cancel';
44740
- const ADD_REPO_HINT = 'type path · tab to complete · enter to add · esc to cancel';
44741
- const CONFIRM_DELETE_HINT = 'press y to remove · any other key to cancel';
44742
- function hintFor(focus) {
45496
+ // Footer hints are split into two slots — same pattern as `coco ui`:
45497
+ // - contextual : per-mode actions (sort, filter, refresh, add/remove)
45498
+ // - global : always-on essentials (help, quit) never crowded out
45499
+ //
45500
+ // The contextual slot drops bindings users can find via the help
45501
+ // overlay (arrow keys, tab); the global slot is the safety net so
45502
+ // `? help` and `q quit` never disappear.
45503
+ const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
45504
+ const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
45505
+ const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
45506
+ const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
45507
+ const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
45508
+ const GLOBAL_HINTS = ['? help', 'q quit'];
45509
+ function contextualHintsFor(focus) {
44743
45510
  switch (focus) {
44744
45511
  case 'sidebar':
44745
- return SIDEBAR_HINT;
45512
+ return SIDEBAR_CONTEXTUAL;
44746
45513
  case 'filter':
44747
- return FILTER_HINT;
45514
+ return FILTER_CONTEXTUAL;
44748
45515
  case 'add-repo':
44749
- return ADD_REPO_HINT;
45516
+ return ADD_REPO_CONTEXTUAL;
44750
45517
  case 'confirm-delete':
44751
- return CONFIRM_DELETE_HINT;
45518
+ return CONFIRM_DELETE_CONTEXTUAL;
44752
45519
  case 'list':
44753
45520
  default:
44754
- return LIST_HINT;
45521
+ return LIST_CONTEXTUAL;
44755
45522
  }
44756
45523
  }
44757
45524
  function buildWorkspaceFooter(state) {
45525
+ const contextual = contextualHintsFor(state.focus);
45526
+ // Modal modes (filter / add-repo / confirm-delete) suppress the
45527
+ // global hints — those bindings are not reachable while a prompt
45528
+ // is open and showing them would be misleading.
45529
+ const isModal = state.focus === 'filter' ||
45530
+ state.focus === 'add-repo' ||
45531
+ state.focus === 'confirm-delete';
45532
+ const global = isModal ? [] : GLOBAL_HINTS;
45533
+ const allHints = [...contextual, ...global];
44758
45534
  return {
44759
- hint: hintFor(state.focus),
45535
+ hint: allHints.join(' · '),
45536
+ contextual,
45537
+ global,
44760
45538
  status: state.status,
44761
45539
  filterMode: state.focus === 'filter',
44762
45540
  };
@@ -44766,11 +45544,26 @@ function buildWorkspaceFooter(state) {
44766
45544
  * sections so users can scan by intent ("how do I navigate?" "how
44767
45545
  * do I act?") rather than reading a flat alphabetized list.
44768
45546
  *
45547
+ * Section order mirrors `coco ui`'s help convention — Essentials
45548
+ * first so newcomers see `?`/`esc`/`q` immediately, then move outward
45549
+ * to navigation, modification, and the destructive verbs last.
45550
+ *
44769
45551
  * The view layer composes these into a panel with section titles,
44770
45552
  * a leading app/title bar, and a closing hint at the bottom.
44771
45553
  */
44772
45554
  function buildWorkspaceHelpSections() {
44773
45555
  return [
45556
+ {
45557
+ title: 'Essentials',
45558
+ subtitle: 'The keys you reach for most often.',
45559
+ rows: [
45560
+ { glyph: '?', keys: '?', description: 'Toggle this help overlay' },
45561
+ { glyph: '⎋', keys: 'esc', description: 'Clear filter / close overlay / cancel prompt' },
45562
+ { glyph: '◴', keys: 'q · ctrl+c', description: 'Quit the workspace surface' },
45563
+ { glyph: '↵', keys: 'enter', description: 'Drill into the cursored repo (coco ui)' },
45564
+ { glyph: '◧', keys: 'T', description: 'Theme picker — browse, live-preview & apply a color theme' },
45565
+ ],
45566
+ },
44774
45567
  {
44775
45568
  title: 'Navigate',
44776
45569
  subtitle: 'Move the cursor and switch focus between panels.',
@@ -44780,7 +45573,6 @@ function buildWorkspaceHelpSections() {
44780
45573
  { glyph: '←', keys: 'h', description: 'Jump focus to the sidebar' },
44781
45574
  { glyph: '→', keys: 'l', description: 'Jump focus to the list' },
44782
45575
  { glyph: '⤒', keys: 'g / G', description: 'Jump to top / bottom of the list' },
44783
- { glyph: '↵', keys: 'enter', description: 'Drill into the cursored repo (coco ui)' },
44784
45576
  ],
44785
45577
  },
44786
45578
  {
@@ -44800,14 +45592,6 @@ function buildWorkspaceHelpSections() {
44800
45592
  { glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
44801
45593
  ],
44802
45594
  },
44803
- {
44804
- title: 'General',
44805
- rows: [
44806
- { glyph: '?', keys: '?', description: 'Toggle this help overlay' },
44807
- { glyph: '⎋', keys: 'esc', description: 'Clear filter / close overlay / cancel prompt' },
44808
- { glyph: '◴', keys: 'q · ctrl+c', description: 'Quit the workspace surface' },
44809
- ],
44810
- },
44811
45595
  ];
44812
45596
  }
44813
45597
  function buildWorkspaceOnboarding(state) {
@@ -45254,13 +46038,19 @@ function renderFooter(deps) {
45254
46038
  // height shifted by a row every time a status banner came and went,
45255
46039
  // forcing the panel chrome to reflow.
45256
46040
  const statusContent = model.status ?? '';
46041
+ const contextualText = model.contextual.join(' ');
46042
+ const globalText = model.global.join(' · ');
45257
46043
  return React.createElement(Box, {
45258
46044
  borderColor: focusBorderColor(theme, false),
45259
46045
  borderStyle: theme.borderStyle,
45260
46046
  paddingX: 1,
45261
46047
  flexDirection: 'column',
45262
46048
  height: FOOTER_HEIGHT,
45263
- }, React.createElement(Text, { dimColor: true }, model.hint), React.createElement(Text, {
46049
+ },
46050
+ // Row 1: contextual ↔ global hints. justifyContent pushes them
46051
+ // to opposite edges so the eye scans each cluster as one block —
46052
+ // same shape as `coco ui`'s footer post-0.54.2 redesign.
46053
+ React.createElement(Box, { flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Text, { dimColor: true }, contextualText), React.createElement(Text, { dimColor: true }, globalText)), React.createElement(Text, {
45264
46054
  color: model.status ? toneColor('warn', theme) : undefined,
45265
46055
  dimColor: !model.status,
45266
46056
  }, statusContent || ' '));
@@ -45296,6 +46086,11 @@ function renderWorkspaceApp(deps) {
45296
46086
  if (deps.state.showHelp) {
45297
46087
  return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderHelpOverlay(deps), renderFooter(deps));
45298
46088
  }
46089
+ // Theme picker is modal too — the chrome live-previews underneath via
46090
+ // the reactive `deps.theme`, while the overlay replaces the body.
46091
+ if (deps.state.showThemePicker) {
46092
+ return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderThemePickerOverlay(React.createElement, { Box: ink.Box, Text: ink.Text }, deps.state.themePickerFilter, deps.state.themePickerIndex, bodyWidth, deps.theme, true), renderFooter(deps));
46093
+ }
45299
46094
  const bodyHeight = computeBodyHeight(deps);
45300
46095
  return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
45301
46096
  }
@@ -45336,6 +46131,37 @@ function resolveWorkspaceInput(input, key, state) {
45336
46131
  }
45337
46132
  return { kind: 'noop' };
45338
46133
  }
46134
+ // Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
46135
+ // move, Enter applies + persists, Esc clears the filter then closes.
46136
+ if (state.showThemePicker) {
46137
+ const filtered = filterThemePresets(state.themePickerFilter);
46138
+ if (key.escape) {
46139
+ if (state.themePickerFilter.length > 0) {
46140
+ return { kind: 'action', action: { type: 'clear-theme-picker-filter' } };
46141
+ }
46142
+ return { kind: 'action', action: { type: 'toggle-theme-picker' } };
46143
+ }
46144
+ if (key.return) {
46145
+ const selected = getThemePickerSelectionFor(state.themePickerFilter, state.themePickerIndex);
46146
+ if (!selected) {
46147
+ return { kind: 'action', action: { type: 'toggle-theme-picker' } };
46148
+ }
46149
+ return { kind: 'apply-theme', preset: selected };
46150
+ }
46151
+ if (key.upArrow || (key.ctrl && input === 'p')) {
46152
+ return { kind: 'action', action: { type: 'move-theme-picker', delta: -1, presetCount: filtered.length } };
46153
+ }
46154
+ if (key.downArrow || (key.ctrl && input === 'n')) {
46155
+ return { kind: 'action', action: { type: 'move-theme-picker', delta: 1, presetCount: filtered.length } };
46156
+ }
46157
+ if (key.backspace || key.delete) {
46158
+ return { kind: 'action', action: { type: 'backspace-theme-picker-filter' } };
46159
+ }
46160
+ if (input && !key.ctrl && !key.meta) {
46161
+ return { kind: 'action', action: { type: 'append-theme-picker-filter', value: input } };
46162
+ }
46163
+ return { kind: 'noop' };
46164
+ }
45339
46165
  if (state.focus === 'filter') {
45340
46166
  if (key.escape) {
45341
46167
  return { kind: 'action', action: { type: 'clear-filter' } };
@@ -45453,6 +46279,12 @@ function resolveWorkspaceInput(input, key, state) {
45453
46279
  if (input === '?') {
45454
46280
  return { kind: 'action', action: { type: 'toggle-help' } };
45455
46281
  }
46282
+ // `T` opens the theme picker (browse + live-preview + apply a color
46283
+ // theme). Single key here rather than `coco ui`'s gC chord since the
46284
+ // workspace keymap is flat (no g-prefix continuations).
46285
+ if (input === 'T') {
46286
+ return { kind: 'action', action: { type: 'toggle-theme-picker' } };
46287
+ }
45456
46288
  return { kind: 'noop' };
45457
46289
  }
45458
46290
 
@@ -45834,6 +46666,7 @@ async function startWorkspace(options) {
45834
46666
  ink,
45835
46667
  React,
45836
46668
  theme,
46669
+ themeConfig: options.theme,
45837
46670
  resumeRef,
45838
46671
  });
45839
46672
  // Override exitOnCtrlC. Ink's default ctrl+c handler reaches into
@@ -45999,6 +46832,17 @@ function WorkspaceInkApp(props) {
45999
46832
  // (see effect below) so idle workspaces don't burn CPU on animation
46000
46833
  // frames.
46001
46834
  const [spinnerTick, setSpinnerTick] = React.useState(0);
46835
+ // Theme picker (`T`) — reactive theme so the chrome live-previews the
46836
+ // cursored theme. `themePreviewPreset` follows the picker cursor while
46837
+ // open; `themeSessionPreset` is the applied choice. The effective theme
46838
+ // rebuilds from the original config; when neither is set we use the
46839
+ // static `props.theme` unchanged (mirrors `coco ui`).
46840
+ const [themePreviewPreset, setThemePreviewPreset] = React.useState(undefined);
46841
+ const [themeSessionPreset, setThemeSessionPreset] = React.useState(undefined);
46842
+ const effectiveThemePreset = themePreviewPreset ?? themeSessionPreset;
46843
+ const theme = React.useMemo(() => effectiveThemePreset
46844
+ ? createLogInkTheme({ ...props.themeConfig, preset: effectiveThemePreset })
46845
+ : props.theme, [effectiveThemePreset, props.themeConfig, props.theme]);
46002
46846
  const dispatch = React.useCallback((action) => {
46003
46847
  setState((prev) => applyWorkspaceAction(prev, action));
46004
46848
  }, []);
@@ -46373,6 +47217,13 @@ function WorkspaceInkApp(props) {
46373
47217
  case 'action':
46374
47218
  dispatch(intent.action);
46375
47219
  break;
47220
+ case 'apply-theme':
47221
+ // Apply for the session + persist to the global config (best-effort),
47222
+ // then close the picker (clearing the preview via the sync effect).
47223
+ setThemeSessionPreset(intent.preset);
47224
+ saveThemePreset(intent.preset);
47225
+ dispatch({ type: 'toggle-theme-picker' });
47226
+ break;
46376
47227
  case 'quit':
46377
47228
  workspaceDebug('→ exit() called from quit intent');
46378
47229
  exitRefHolder.current.current = { kind: 'quit' };
@@ -46422,11 +47273,20 @@ function WorkspaceInkApp(props) {
46422
47273
  filter: state.filter,
46423
47274
  });
46424
47275
  }, [props.roots, state.sortMode, state.tab, state.filter]);
47276
+ // Keep the live preview in sync with the preset under the picker cursor
47277
+ // while the overlay is open; clear it on close so the theme reverts to
47278
+ // the applied session preset (or the original config theme).
47279
+ const themePickerSelection = state.showThemePicker
47280
+ ? getThemePickerSelectionFor(state.themePickerFilter, state.themePickerIndex)
47281
+ : undefined;
47282
+ React.useEffect(() => {
47283
+ setThemePreviewPreset(state.showThemePicker ? themePickerSelection : undefined);
47284
+ }, [state.showThemePicker, themePickerSelection]);
46425
47285
  return renderWorkspaceApp({
46426
47286
  React,
46427
47287
  ink: { Box: ink.Box, Text: ink.Text },
46428
47288
  state,
46429
- theme: props.theme,
47289
+ theme,
46430
47290
  appLabel: props.appLabel,
46431
47291
  filterDraft,
46432
47292
  addRepoDraft,
@@ -46437,8 +47297,20 @@ function WorkspaceInkApp(props) {
46437
47297
  });
46438
47298
  }
46439
47299
 
46440
- const DEFAULT_ROOTS = ['~/code'];
46441
- function resolveWorkspaceRoots(argv, config) {
47300
+ /**
47301
+ * Resolve the directories the workspace scans for git repos, in
47302
+ * precedence order:
47303
+ * 1. `--root` CLI flag(s)
47304
+ * 2. `workspace.roots` from config
47305
+ * 3. the current working directory (`cwd`)
47306
+ *
47307
+ * Falling back to `cwd` (rather than a hardcoded `~/code`) means a bare
47308
+ * `coco` / `coco ws` discovers repos wherever you launched it — the
47309
+ * handler chdir's to honor `--repo` / `--cwd` before this runs, so `cwd`
47310
+ * already reflects the targeted directory. `cwd` is a parameter (not a
47311
+ * direct `process.cwd()` call) so the resolver stays a pure unit.
47312
+ */
47313
+ function resolveWorkspaceRoots(argv, config, cwd = process.cwd()) {
46442
47314
  const raw = argv.root;
46443
47315
  if (Array.isArray(raw) && raw.length > 0) {
46444
47316
  return raw.map((entry) => String(entry));
@@ -46449,7 +47321,7 @@ function resolveWorkspaceRoots(argv, config) {
46449
47321
  if (config.workspace?.roots && config.workspace.roots.length > 0) {
46450
47322
  return [...config.workspace.roots];
46451
47323
  }
46452
- return [...DEFAULT_ROOTS];
47324
+ return [cwd];
46453
47325
  }
46454
47326
  function resolveWorkspaceKnownRepos(config) {
46455
47327
  return config.workspace?.knownRepos ? [...config.workspace.knownRepos] : [];
@@ -46563,6 +47435,148 @@ var workspace = {
46563
47435
  options,
46564
47436
  };
46565
47437
 
47438
+ /**
47439
+ * Default-command router for `coco` invoked with no positional
47440
+ * arguments. Decides where to send the user based on the state of
47441
+ * their machine:
47442
+ *
47443
+ * ┌─────────────────────────┬─────────────────────┬──────────────┐
47444
+ * │ Config present? │ In a git repo? │ Action │
47445
+ * ├─────────────────────────┼─────────────────────┼──────────────┤
47446
+ * │ No (default-only) │ — │ run `init` │
47447
+ * │ Yes │ Yes (worktree) │ run `ui` │
47448
+ * │ Yes │ No │ run `ws` │
47449
+ * └─────────────────────────┴─────────────────────┴──────────────┘
47450
+ *
47451
+ * The pre-existing default — fall through to `commit` — was hostile
47452
+ * to first-time users: a fresh install with no config landed
47453
+ * straight in the API-key error path, with no hint that `coco init`
47454
+ * was the right next step. Routing fresh installs to `init` and
47455
+ * configured users to the workstation/UI matches what every other
47456
+ * git-aware CLI does (lazygit, tig, gitui all open their TUI on bare
47457
+ * invocation).
47458
+ *
47459
+ * `coco commit` keeps its dedicated subcommand entry so existing
47460
+ * scripts (`git aliases`, hook integrations, CI jobs) that call
47461
+ * `coco commit` continue to work unchanged.
47462
+ *
47463
+ * The router is a thin shim — it forwards to the existing handlers
47464
+ * via their public exports rather than re-implementing the logic.
47465
+ */
47466
+ /**
47467
+ * Pure decision function — given probed signals (whether config
47468
+ * exists, whether the current directory is a git repo, whether the
47469
+ * user opted into legacy commit-by-default), decides which command
47470
+ * to invoke. Kept pure so unit tests can cover every quadrant of
47471
+ * the router table without spawning processes.
47472
+ *
47473
+ * "Config exists" is defined as: the loader detected at least one
47474
+ * source beyond `default` — i.e., the user has either a project
47475
+ * config, a git config `[coco]` section, an env var, or an XDG
47476
+ * config. A pure-defaults run is treated as "never been configured"
47477
+ * because `coco init` is the only way to populate any of those
47478
+ * sources.
47479
+ */
47480
+ function decideDefaultRoute(input) {
47481
+ if (input.envOverride === 'commit' || input.explicitCommit) {
47482
+ return {
47483
+ kind: 'commit',
47484
+ reason: input.envOverride === 'commit' ? 'env-override' : 'explicit-flag',
47485
+ };
47486
+ }
47487
+ if (!input.hasConfigSource) {
47488
+ return { kind: 'init', reason: 'no-config' };
47489
+ }
47490
+ if (input.isGitRepo) {
47491
+ return { kind: 'ui', reason: 'config-and-repo' };
47492
+ }
47493
+ return { kind: 'workspace', reason: 'config-no-repo' };
47494
+ }
47495
+ /**
47496
+ * Probe whether the cwd (after `--repo` is honored) is inside a git
47497
+ * worktree. Tolerant of every error class — a thrown simple-git
47498
+ * call should never block the router; it should fall back to
47499
+ * "not a repo" so the user lands somewhere sensible (workspace
47500
+ * surface) rather than crashing on an empty machine.
47501
+ */
47502
+ async function probeIsGitRepo() {
47503
+ try {
47504
+ // Lazy-import simple-git so the cold-start path stays fast for
47505
+ // users running `coco --help` / `coco doctor` etc.
47506
+ const { default: simpleGit } = await import('simple-git');
47507
+ const git = simpleGit();
47508
+ return await git.checkIsRepo();
47509
+ }
47510
+ catch {
47511
+ return false;
47512
+ }
47513
+ }
47514
+ /**
47515
+ * Build a synthetic argv for one of the targeted handlers. Each
47516
+ * handler reads its own typed argv contract (`CommitArgv`,
47517
+ * `InitArgv`, `UiArgv`, `WorkspaceArgv`) so we can't just spread the
47518
+ * raw default argv — we have to project the shared fields and let
47519
+ * the handler fill in command-specific defaults.
47520
+ */
47521
+ function buildSyntheticArgv(argv) {
47522
+ return {
47523
+ _: ['$0'],
47524
+ $0: argv.$0,
47525
+ repo: argv.repo,
47526
+ cwd: argv.cwd,
47527
+ verbose: argv.verbose ?? false,
47528
+ interactive: true,
47529
+ version: false,
47530
+ help: false,
47531
+ };
47532
+ }
47533
+ /**
47534
+ * Default-command handler installed under yargs's `$0` slot. Probes
47535
+ * the environment, computes the right route, and forwards to the
47536
+ * matching command handler. Falls through to commit if any
47537
+ * unexpected error blocks routing — preserves backwards-compat
47538
+ * for users on weird setups while still giving newcomers the
47539
+ * onboarding path they deserve.
47540
+ */
47541
+ const defaultRouteHandler = async (argv, logger) => {
47542
+ // The `--repo` flag has to land before any probe runs — otherwise
47543
+ // we'd sniff the launcher's cwd instead of the targeted repo.
47544
+ applyRepoCwd(argv);
47545
+ // Trigger a config load so `getConfigSources()` returns the active
47546
+ // source list. We discard the config object — the decision only
47547
+ // cares about which sources contributed.
47548
+ void loadConfig(argv);
47549
+ const sources = getConfigSources();
47550
+ const hasConfigSource = sources.some((source) => source.source !== 'default');
47551
+ const isGitRepo = await probeIsGitRepo();
47552
+ const decision = decideDefaultRoute({
47553
+ hasConfigSource,
47554
+ isGitRepo,
47555
+ explicitCommit: Boolean(argv.commit),
47556
+ envOverride: process.env.COCO_DEFAULT,
47557
+ });
47558
+ switch (decision.kind) {
47559
+ case 'init':
47560
+ // Friendly hint before the wizard kicks in — sets expectations
47561
+ // that the user is being walked through setup, not silently
47562
+ // routed to a different command.
47563
+ logger.log('No coco config detected — running `coco init` to set up your provider + key.', { color: 'cyan' });
47564
+ logger.log('');
47565
+ await handler$7(buildSyntheticArgv(argv), logger);
47566
+ return;
47567
+ case 'ui':
47568
+ await handler$5(buildSyntheticArgv(argv));
47569
+ return;
47570
+ case 'workspace':
47571
+ await handler(buildSyntheticArgv(argv));
47572
+ return;
47573
+ case 'commit':
47574
+ default:
47575
+ await handler$9(buildSyntheticArgv(argv), logger);
47576
+ return;
47577
+ }
47578
+ };
47579
+
46566
47580
  var types = /*#__PURE__*/Object.freeze({
46567
47581
  __proto__: null
46568
47582
  });
@@ -46581,7 +47595,37 @@ y.option('repo', {
46581
47595
  description: 'Target a specific repository directory instead of the current working directory.',
46582
47596
  global: true,
46583
47597
  });
46584
- y.command([commit.command, '$0'], commit.desc, commit.builder, commit.handler);
47598
+ // Global `--verbose` (alias `-v`) every subcommand inherits it.
47599
+ // Flips `argv.verbose: true` so `commandExecutor` and `Logger` print
47600
+ // stack traces / debug spans. Previously only settable via the
47601
+ // `COCO_VERBOSE=true` env var or `coco.verbose` git/json config —
47602
+ // `BaseArgvOptions.verbose` was typed but never declared as a yargs
47603
+ // option, so passing `--verbose` from the CLI was a silent no-op.
47604
+ y.option('verbose', {
47605
+ type: 'boolean',
47606
+ alias: 'v',
47607
+ description: 'Print verbose diagnostic output (stack traces on errors, debug spans).',
47608
+ default: false,
47609
+ global: true,
47610
+ });
47611
+ // `$0` (no positional args) routes through the smart default router
47612
+ // rather than aliasing directly to `coco commit`. The router probes
47613
+ // the user's environment (config presence, git-repo presence) and
47614
+ // forwards to `init` / `ui` / `workspace` / `commit` based on which
47615
+ // of those is most likely to be helpful. Mirrors what other modern
47616
+ // git-aware CLIs do (lazygit / tig / gitui) — fresh installs land in
47617
+ // a setup wizard, configured users land in the TUI, scripts that
47618
+ // rely on `coco commit` keep their dedicated subcommand entry.
47619
+ y.command('$0', 'Smart entry point — routes to init / ui / workspace / commit based on your environment.', (yargs) => yargs.option('commit', {
47620
+ type: 'boolean',
47621
+ description: 'Force the legacy default — run `coco commit` regardless of routing.',
47622
+ default: false,
47623
+ }),
47624
+ // `commandExecutor` wraps every command with config loading, error
47625
+ // formatting, and exit-code handling. The router is a regular
47626
+ // command so it lights up the same plumbing for free.
47627
+ commandExecutor(defaultRouteHandler));
47628
+ y.command(commit.command, commit.desc, commit.builder, commit.handler);
46585
47629
  y.command(changelog.command, changelog.desc, changelog.builder, changelog.handler);
46586
47630
  y.command(recap.command, recap.desc, recap.builder, recap.handler);
46587
47631
  y.command(review.command, review.desc, review.builder, review.handler);