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.
@@ -22,7 +22,7 @@ import ora from 'ora';
22
22
  import now from 'performance-now';
23
23
  import prettyMilliseconds from 'pretty-ms';
24
24
  import * as fs$1 from 'node:fs';
25
- import { existsSync, mkdirSync, unlinkSync, statSync, writeFileSync, renameSync, mkdtempSync, rmSync, readFileSync as readFileSync$1 } from 'node:fs';
25
+ import { existsSync, unlinkSync, statSync, mkdirSync, writeFileSync, renameSync, mkdtempSync, rmSync, readFileSync as readFileSync$1 } from 'node:fs';
26
26
  import * as os$1 from 'node:os';
27
27
  import { platform, homedir, tmpdir as tmpdir$1 } from 'node:os';
28
28
  import * as path$1 from 'node:path';
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.56.0";
64
+ const BUILD_VERSION = "0.58.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -1160,10 +1160,7 @@ const schema$1 = {
1160
1160
  "items": {
1161
1161
  "type": "string"
1162
1162
  },
1163
- "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.",
1164
- "default": [
1165
- "~/code"
1166
- ]
1163
+ "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.)"
1167
1164
  },
1168
1165
  "knownRepos": {
1169
1166
  "type": "array",
@@ -2139,6 +2136,10 @@ const schema$1 = {
2139
2136
  "selection": {
2140
2137
  "type": "string"
2141
2138
  },
2139
+ "selectionForeground": {
2140
+ "type": "string",
2141
+ "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`."
2142
+ },
2142
2143
  "success": {
2143
2144
  "type": "string"
2144
2145
  },
@@ -2182,7 +2183,25 @@ const schema$1 = {
2182
2183
  "vitesse-dark",
2183
2184
  "vesper",
2184
2185
  "flexoki",
2185
- "mellow"
2186
+ "mellow",
2187
+ "night-owl",
2188
+ "cobalt2",
2189
+ "oceanic-next",
2190
+ "catppuccin-macchiato",
2191
+ "gruvbox-light",
2192
+ "tokyo-night-day",
2193
+ "one-light",
2194
+ "ayu-light",
2195
+ "rose-pine-dawn",
2196
+ "everforest-light",
2197
+ "vitesse-light",
2198
+ "dayfox",
2199
+ "night-owl-light",
2200
+ "flexoki-light",
2201
+ "material-lighter",
2202
+ "papercolor-light",
2203
+ "modus-operandi",
2204
+ "quiet-light"
2186
2205
  ]
2187
2206
  }
2188
2207
  }
@@ -15212,10 +15231,19 @@ const CommitSplitPlanSchema = objectType({
15212
15231
  title: stringType().min(1),
15213
15232
  body: stringType().optional(),
15214
15233
  rationale: stringType().optional(),
15215
- files: arrayType(stringType()),
15216
- hunks: arrayType(stringType()),
15234
+ // Both optional: the model legitimately emits a group with *either*
15235
+ // `files` or `hunks` (a file-level vs hunk-level grouping), not always
15236
+ // both. Requiring both made Zod throw "Required" and the whole split
15237
+ // chain failed to parse before the refine could run. The refine below
15238
+ // still enforces "at least one", and every downstream consumer already
15239
+ // reads these as `group.files || []`. (Kept `.optional()` rather than
15240
+ // `.default([])` so the schema's input and output types stay identical
15241
+ // — `executeChainWithSchema` takes a `z.ZodSchema<T>`, which requires
15242
+ // that.)
15243
+ files: arrayType(stringType()).optional(),
15244
+ hunks: arrayType(stringType()).optional(),
15217
15245
  })
15218
- .refine((group) => group.files.length > 0 || group.hunks.length > 0, {
15246
+ .refine((group) => (group.files?.length ?? 0) > 0 || (group.hunks?.length ?? 0) > 0, {
15219
15247
  message: 'Each group must include at least one file or hunk',
15220
15248
  }))
15221
15249
  .min(1),
@@ -22813,6 +22841,13 @@ const LOG_INK_KEY_BINDINGS = [
22813
22841
  description: 'Create a lightweight tag at the cursored commit.',
22814
22842
  contexts: ['history'],
22815
22843
  },
22844
+ {
22845
+ id: 'themePicker',
22846
+ keys: ['gC'],
22847
+ label: 'theme picker',
22848
+ description: 'Browse, live-preview, and apply a color theme.',
22849
+ contexts: ['normal'],
22850
+ },
22816
22851
  {
22817
22852
  id: 'viewChangelog',
22818
22853
  keys: ['L'],
@@ -22902,6 +22937,7 @@ const BINDING_CATEGORY_BY_ID = {
22902
22937
  // them above everything else.
22903
22938
  help: 'essentials',
22904
22939
  commandPalette: 'essentials',
22940
+ themePicker: 'view',
22905
22941
  quit: 'essentials',
22906
22942
  refresh: 'essentials',
22907
22943
  navigateBack: 'essentials',
@@ -23537,635 +23573,1496 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
23537
23573
  }
23538
23574
 
23539
23575
  /**
23540
- * Canned filter presets for the issue / PR triage TUI views
23541
- * (#882 phase 6). Each preset compiles to the same shape the
23542
- * underlying list fetchers (`getIssueList` / `getPullRequestList`)
23543
- * already accept — there's no new `gh` surface area, just a
23544
- * curated set of common triage angles surfaced as a single
23545
- * keystroke (`f` cycles).
23576
+ * Explicit color-level detection for the Ink TUI (P5.2).
23546
23577
  *
23547
- * The presets are deliberately *not* a 1:1 mirror across the two
23548
- * surfaces:
23578
+ * Chalk already approximates hex colors when the terminal can't render
23579
+ * truecolor — but we want an explicit signal so the catppuccin / gruvbox
23580
+ * presets (which use hex) can fall back to the ANSI-named `default` preset
23581
+ * cleanly on minimal SSH sessions, instead of relying on chalk's
23582
+ * heuristics. Users who set `NO_COLOR` or pick the `monochrome` preset
23583
+ * still get the manual override.
23549
23584
  *
23550
- * - Issues have no draft / mergeable concept, so `draft` /
23551
- * `mergeable` are skipped on the issue list.
23552
- * - PRs have a `merged` state distinct from `closed`; issues
23553
- * don't.
23554
- * - `mine` semantics differ subtly: for issues it tends to
23555
- * mean "I'm the assignee" (issues are tasks people pick up);
23556
- * for PRs it means "I'm the author" (PRs are work people
23557
- * post). The presets bake those in so the user doesn't have
23558
- * to think about it.
23559
- */
23560
- /** Cycle order — must match the keystroke walk on `f`. */
23561
- const ISSUE_FILTER_PRESETS = [
23562
- 'open',
23563
- 'closed',
23564
- 'mine',
23565
- 'assigned',
23566
- ];
23567
- const PULL_REQUEST_FILTER_PRESETS = [
23568
- 'open',
23569
- 'draft',
23570
- 'mine',
23571
- 'assigned',
23572
- 'closed',
23573
- 'merged',
23574
- ];
23575
- const ISSUE_FILTER_LABELS = {
23576
- open: 'open',
23577
- closed: 'closed',
23578
- mine: 'mine (assigned)',
23579
- assigned: 'assigned to me',
23580
- };
23581
- const PULL_REQUEST_FILTER_LABELS = {
23582
- open: 'open',
23583
- draft: 'draft',
23584
- mine: 'mine (authored)',
23585
- assigned: 'assigned to me',
23586
- closed: 'closed',
23587
- merged: 'merged',
23588
- };
23589
- /**
23590
- * Resolve a preset to the filter object the data fetcher accepts.
23591
- * Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
23592
- * `getPullRequestList` so unit tests can assert the mapping
23593
- * independently from the fetch pipeline.
23585
+ * Levels (matching the chalk taxonomy):
23586
+ * - 'mono' → no ANSI escapes at all (NO_COLOR / TERM=dumb)
23587
+ * - '16' → standard 16-color ANSI palette
23588
+ * - '256' → xterm-256color
23589
+ * - 'truecolor' 24-bit RGB (COLORTERM=truecolor or known terminals)
23594
23590
  */
23595
- function issueFilterForPreset(preset) {
23596
- switch (preset) {
23597
- case 'open':
23598
- return { state: 'open' };
23599
- case 'closed':
23600
- return { state: 'closed' };
23601
- case 'mine':
23602
- // Issues are tasks — "mine" is what *I'm working on*, i.e.
23603
- // assigned to me + still open. Same as `assigned` plus the
23604
- // open-state filter for ergonomic single-keystroke focus on
23605
- // the active backlog.
23606
- return { state: 'open', assignee: '@me' };
23607
- case 'assigned':
23608
- return { assignee: '@me' };
23591
+ function getColorLevel(env = process.env) {
23592
+ if (env.NO_COLOR)
23593
+ return 'mono';
23594
+ switch (env.FORCE_COLOR) {
23595
+ case '0':
23596
+ return 'mono';
23597
+ case '1':
23598
+ return '16';
23599
+ case '2':
23600
+ return '256';
23601
+ case '3':
23602
+ return 'truecolor';
23609
23603
  }
23610
- }
23611
- function pullRequestFilterForPreset(preset) {
23612
- switch (preset) {
23613
- case 'open':
23614
- return { state: 'open' };
23615
- case 'draft':
23616
- // gh's `--draft` flag implies `--state open`; surface that
23617
- // explicitly so the canonicalize step doesn't elide it.
23618
- return { state: 'open', draft: true };
23619
- case 'mine':
23620
- // PRs are work — "mine" is what *I authored*. Most useful
23621
- // when looking at one's own backlog of in-flight PRs.
23622
- return { state: 'open', author: '@me' };
23623
- case 'assigned':
23624
- return { assignee: '@me' };
23625
- case 'closed':
23626
- return { state: 'closed' };
23627
- case 'merged':
23628
- return { state: 'merged' };
23604
+ const colorterm = env.COLORTERM?.toLowerCase();
23605
+ if (colorterm === 'truecolor' || colorterm === '24bit') {
23606
+ return 'truecolor';
23629
23607
  }
23630
- }
23631
- function cycleIssueFilterPreset(current) {
23632
- const index = ISSUE_FILTER_PRESETS.indexOf(current);
23633
- const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
23634
- return ISSUE_FILTER_PRESETS[next];
23635
- }
23636
- function cyclePullRequestFilterPreset(current) {
23637
- const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
23638
- const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
23639
- return PULL_REQUEST_FILTER_PRESETS[next];
23640
- }
23641
-
23642
- /**
23643
- * Sort modes for the promoted views (P4.2).
23644
- *
23645
- * Pure: takes existing context entries + the active mode, returns a sorted
23646
- * copy. Tested in isolation; the runtime just calls these helpers.
23647
- *
23648
- * Display label uses `▼` (U+25BC) under truecolor / UTF-8 and falls back to
23649
- * `v` under ASCII. Letters (`recent` / `name` / `ahead`) carry meaning;
23650
- * shape enhances.
23651
- */
23652
- const BRANCH_SORT_MODES = ['name', 'recent', 'ahead'];
23653
- const DEFAULT_BRANCH_SORT_MODE = 'name';
23654
- function cycleBranchSort(mode) {
23655
- const index = BRANCH_SORT_MODES.indexOf(mode);
23656
- if (index < 0)
23657
- return BRANCH_SORT_MODES[0];
23658
- return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
23659
- }
23660
- function sortBranches(branches, mode) {
23661
- // Pin the current branch at index 0 regardless of sort mode (#806
23662
- // follow-up). Lands the user's cursor on the active branch by
23663
- // default and keeps the most-relevant row glued to the top of the
23664
- // list as they cycle sorts.
23665
- const current = branches.find((entry) => entry.current);
23666
- const rest = branches.filter((entry) => !entry.current);
23667
- const sortedRest = rest.slice();
23668
- switch (mode) {
23669
- case 'name':
23670
- sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
23671
- break;
23672
- case 'recent':
23673
- // ISO-shaped dates compare byte-for-byte; descending so the freshest
23674
- // branch sits at the top.
23675
- sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
23676
- a.shortName.localeCompare(b.shortName));
23677
- break;
23678
- case 'ahead':
23679
- // ahead-first; ties broken by behind, then by name. Keeps "this branch
23680
- // has unmerged work" in the user's first scroll.
23681
- sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
23682
- a.shortName.localeCompare(b.shortName));
23683
- break;
23608
+ // Modern terminal emulators that publicly advertise truecolor support.
23609
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
23610
+ return 'truecolor';
23684
23611
  }
23685
- return current ? [current, ...sortedRest] : sortedRest;
23686
- }
23687
- const TAG_SORT_MODES = ['recent', 'name'];
23688
- const DEFAULT_TAG_SORT_MODE = 'recent';
23689
- function cycleTagSort(mode) {
23690
- const index = TAG_SORT_MODES.indexOf(mode);
23691
- if (index < 0)
23692
- return TAG_SORT_MODES[0];
23693
- return TAG_SORT_MODES[(index + 1) % TAG_SORT_MODES.length];
23694
- }
23695
- function sortTags(tags, mode) {
23696
- const copy = tags.slice();
23697
- switch (mode) {
23698
- case 'name':
23699
- return copy.sort((a, b) => a.name.localeCompare(b.name));
23700
- case 'recent':
23701
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
23702
- a.name.localeCompare(b.name));
23703
- default:
23704
- return copy;
23612
+ if (env.WT_SESSION) {
23613
+ return 'truecolor';
23705
23614
  }
23615
+ switch (env.TERM_PROGRAM) {
23616
+ case 'iTerm.app':
23617
+ case 'WezTerm':
23618
+ case 'vscode':
23619
+ case 'ghostty':
23620
+ case 'Hyper':
23621
+ return 'truecolor';
23622
+ }
23623
+ if (env.TERM === 'dumb')
23624
+ return 'mono';
23625
+ if (env.TERM?.includes('256color'))
23626
+ return '256';
23627
+ return '16';
23706
23628
  }
23707
- /* ---------------------------- header indicator -------------------------- */
23708
- function formatSortIndicator(mode, options = {}) {
23709
- return `${options.ascii ? 'v' : '▼'} ${mode}`;
23710
- }
23711
-
23712
- const DEFAULT_CHANGELOG_VIEW_STATE = {
23713
- status: 'idle',
23714
- scrollOffset: 0,
23715
- };
23716
- const DEFAULT_LOG_INK_STATUS_FILTER_MASK = {
23717
- staged: true,
23718
- unstaged: true,
23719
- untracked: true,
23720
- };
23629
+ 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']);
23721
23630
  /**
23722
- * Detect a history server-side filter prefix (#776). Returns the parsed
23723
- * `LogInkHistoryFetchArgs` for `path:<value>` and `author:<value>`
23724
- * prefixes, or `undefined` for a plain (client-side) filter. The whole
23725
- * remainder of the string (post-prefix) becomes the value — paths and
23726
- * author names commonly contain spaces, and we don't try to parse
23727
- * shell-like syntax.
23631
+ * `true` when the named preset relies on hex colors that look best under
23632
+ * 24-bit RGB. Used by `createLogInkTheme` to decide whether to downgrade
23633
+ * to the ANSI-named `default` palette on lower-capability terminals.
23728
23634
  */
23729
- function parseLogInkHistoryFetchPrefix(filter) {
23730
- const trimmed = filter.trim();
23731
- if (trimmed.startsWith('path:')) {
23732
- const value = trimmed.slice('path:'.length).trim();
23733
- return value ? { path: value } : undefined;
23734
- }
23735
- if (trimmed.startsWith('author:')) {
23736
- const value = trimmed.slice('author:'.length).trim();
23737
- return value ? { author: value } : undefined;
23738
- }
23739
- return undefined;
23740
- }
23741
- const FOCUS_ORDER = ['sidebar', 'commits', 'detail'];
23742
- const SIDEBAR_TABS = ['status', 'branches', 'tags', 'stashes', 'worktrees'];
23743
- function searchableFields(commit) {
23744
- return [
23745
- commit.shortHash,
23746
- commit.hash,
23747
- commit.date,
23748
- commit.author,
23749
- commit.message,
23750
- ...commit.refs,
23751
- ];
23752
- }
23753
- function scoreField(field, term) {
23754
- const value = field.toLowerCase();
23755
- const normalized = term.toLowerCase();
23756
- if (!normalized) {
23757
- return 0;
23758
- }
23759
- if (value === normalized) {
23760
- return 1000;
23761
- }
23762
- if (value.startsWith(normalized)) {
23763
- return 800 - Math.min(value.length - normalized.length, 200);
23764
- }
23765
- const substringIndex = value.indexOf(normalized);
23766
- if (substringIndex >= 0) {
23767
- return 600 - Math.min(substringIndex, 200);
23768
- }
23769
- let searchIndex = 0;
23770
- let distance = 0;
23771
- for (const character of normalized) {
23772
- const nextIndex = value.indexOf(character, searchIndex);
23773
- if (nextIndex < 0) {
23774
- return undefined;
23775
- }
23776
- distance += nextIndex - searchIndex;
23777
- searchIndex = nextIndex + 1;
23778
- }
23779
- return 300 - Math.min(distance, 200);
23780
- }
23781
- function scoreLogInkCommitFilter(commit, filter) {
23782
- const terms = filter.trim().split(/\s+/).filter(Boolean);
23783
- if (terms.length === 0) {
23784
- return 0;
23785
- }
23786
- const fields = searchableFields(commit);
23787
- let score = 0;
23788
- for (const term of terms) {
23789
- const bestFieldScore = fields.reduce((best, field) => {
23790
- const fieldScore = scoreField(field, term);
23791
- if (fieldScore === undefined) {
23792
- return best;
23793
- }
23794
- return best === undefined ? fieldScore : Math.max(best, fieldScore);
23795
- }, undefined);
23796
- if (bestFieldScore === undefined) {
23797
- return undefined;
23798
- }
23799
- score += bestFieldScore;
23800
- }
23801
- return score;
23802
- }
23803
- function filterCommits(commits, filter) {
23804
- return commits
23805
- .map((commit, index) => ({
23806
- commit,
23807
- index,
23808
- score: scoreLogInkCommitFilter(commit, filter),
23809
- }))
23810
- .filter((entry) => entry.score !== undefined)
23811
- .sort((a, b) => b.score - a.score || a.index - b.index)
23812
- .map((entry) => entry.commit);
23813
- }
23814
- function clampIndex(index, length) {
23815
- if (length === 0) {
23816
- return 0;
23817
- }
23818
- return Math.max(0, Math.min(index, length - 1));
23819
- }
23820
- function cycleValue(values, current, delta) {
23821
- const currentIndex = Math.max(0, values.indexOf(current));
23822
- const nextIndex = (currentIndex + delta + values.length) % values.length;
23823
- return values[nextIndex];
23824
- }
23825
- const HOME_VIEW = 'history';
23826
- function topOfStack(stack) {
23827
- return stack[stack.length - 1];
23828
- }
23829
- function withPushedView(state, value) {
23830
- if (topOfStack(state.viewStack) === value) {
23831
- return { ...state, pendingKey: undefined };
23832
- }
23833
- const viewStack = [...state.viewStack, value];
23834
- return {
23835
- ...state,
23836
- activeView: value,
23837
- viewStack,
23838
- // The compose + status views' right detail panels already show
23839
- // worktree info, so keeping the left sidebar on the Status tab
23840
- // duplicates that information. Auto-switch to Branches when entering
23841
- // either view; the user can swap back with [/] if they want.
23842
- //
23843
- // We update only the rendered `sidebarTab` here, never
23844
- // `userSidebarTab`, so this auto-switch is invisible to per-repo
23845
- // persistence and pop-view restores the previous tab.
23846
- sidebarTab: value === 'compose' || value === 'status' ? 'branches' : state.sidebarTab,
23847
- worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
23848
- selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
23849
- diffSource: value === 'diff' ? state.diffSource : undefined,
23850
- stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
23851
- compareHead: value === 'diff' ? state.compareHead : undefined,
23852
- pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
23853
- statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
23854
- pendingKey: undefined,
23855
- };
23856
- }
23857
- function withPoppedView(state) {
23858
- if (state.viewStack.length <= 1) {
23859
- return { ...state, pendingKey: undefined };
23860
- }
23861
- const viewStack = state.viewStack.slice(0, -1);
23862
- const next = topOfStack(viewStack);
23863
- // #779 — compareBase is "cleared when the diff view is popped." We
23864
- // detect that case by checking if the *previous* top was 'diff'.
23865
- // The compare workflow ends when the user backs out of the compare
23866
- // diff; on the next mark they re-set the base. Other view pops
23867
- // preserve compareBase so the user can move between branches / tags /
23868
- // history while hunting for a head ref.
23869
- const wasOnDiff = state.activeView === 'diff';
23870
- return {
23871
- ...state,
23872
- activeView: next,
23873
- viewStack,
23874
- // Restore the user's last explicit tab choice so popping out of
23875
- // compose / status (which auto-switch the sidebar to Branches)
23876
- // returns the user to whatever they actually had open before.
23877
- sidebarTab: state.userSidebarTab,
23878
- worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
23879
- selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
23880
- diffSource: next === 'diff' ? state.diffSource : undefined,
23881
- stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
23882
- compareBase: wasOnDiff ? undefined : state.compareBase,
23883
- compareHead: next === 'diff' ? state.compareHead : undefined,
23884
- pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
23885
- statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
23886
- pendingKey: undefined,
23887
- };
23635
+ function presetUsesTrueColor(preset) {
23636
+ return preset !== undefined && TRUECOLOR_PRESETS.has(preset);
23888
23637
  }
23889
23638
  /**
23890
- * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
23891
- * the active view position into the new frame's `parentReturn` so a
23892
- * subsequent pop lands the user back where they came from, then
23893
- * resets the per-frame navigation state (active view, view stack,
23894
- * row / file / submodule cursors, filter) so the nested frame opens
23895
- * in a clean slate — the mental equivalent of a fresh `coco ui`
23896
- * launched against the submodule's working dir.
23897
- *
23898
- * Sidebar tab + branch / tag sort are also captured into the return
23899
- * snapshot (#995) so popping back restores the parent's choices
23900
- * instead of letting the submodule's tab/sort bleed across the
23901
- * boundary. The values on the *new* frame are left as-is (carried
23902
- * over from the parent) — the load effect in app.ts re-reads
23903
- * persistence keyed on the submodule's workdir and dispatches a
23904
- * restore if the user has a submodule-specific saved preference.
23905
- *
23906
- * Other preferences (palette recents, inspector tab, diff view mode)
23907
- * stay global by design — the user's preference shouldn't reset when
23908
- * they cross a submodule boundary.
23909
- *
23910
- * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
23911
- * outside the reducer in `app.ts`'s parallel ref structure — this
23912
- * helper only manages the pure view-model side of the push.
23639
+ * WCAG 2.x relative luminance of a `#rrggbb` color, 0 (black) … 1 (white).
23640
+ * Returns `null` for anything that isn't a 6-digit hex (e.g. ANSI-named
23641
+ * colors), so callers can fall back rather than guess.
23913
23642
  */
23914
- function withPushedRepoFrame(state, payload) {
23915
- const newFrame = {
23916
- label: payload.label,
23917
- workdir: payload.workdir,
23918
- entryRange: payload.entryRange,
23919
- parentReturn: {
23920
- activeView: state.activeView,
23921
- selectedIndex: state.selectedIndex,
23922
- selectedFileIndex: state.selectedFileIndex,
23923
- selectedSubmoduleIndex: state.selectedSubmoduleIndex,
23924
- filter: state.filter,
23925
- sidebarTab: state.sidebarTab,
23926
- userSidebarTab: state.userSidebarTab,
23927
- branchSort: state.branchSort,
23928
- tagSort: state.tagSort,
23929
- },
23930
- };
23931
- return {
23932
- ...state,
23933
- repoStack: [...state.repoStack, newFrame],
23934
- activeView: 'history',
23935
- viewStack: ['history'],
23936
- selectedIndex: 0,
23937
- selectedFileIndex: 0,
23938
- selectedSubmoduleIndex: 0,
23939
- filter: '',
23940
- filterMode: false,
23941
- pendingCommitFocused: false,
23942
- pendingKey: undefined,
23943
- pendingConfirmationId: undefined,
23944
- pendingConfirmationPayload: undefined,
23945
- pendingMutationConfirmation: undefined,
23643
+ function relativeLuminance(hex) {
23644
+ const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
23645
+ if (!match)
23646
+ return null;
23647
+ const int = parseInt(match[1], 16);
23648
+ const channel = (c) => {
23649
+ const x = c / 255;
23650
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
23946
23651
  };
23652
+ const r = channel((int >> 16) & 0xff);
23653
+ const g = channel((int >> 8) & 0xff);
23654
+ const b = channel(int & 0xff);
23655
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23947
23656
  }
23948
23657
  /**
23949
- * Pop the top repo frame off `state.repoStack` (#931) and restore
23950
- * the parent's view position from the captured `parentReturn`. A
23951
- * no-op when the stack is already at its single root frame so this
23952
- * action is safe to dispatch from generic input handlers (e.g. the
23953
- * Esc auto-pop wiring that lands in a follow-up PR).
23658
+ * Pick a foreground guaranteed to stay readable on `bg` black for light
23659
+ * backgrounds, white for dark ones. The 0.179 threshold is the luminance
23660
+ * crossover where black and white yield identical contrast, so the choice
23661
+ * always maximizes it; every background clears WCAG AA (≥ 4.5:1).
23954
23662
  *
23955
- * The defensive `parentReturn` fallback handles the never-supposed-
23956
- * to-happen case where a non-root frame somehow has no return state
23957
- * recorded drop the frame but leave the user's view position
23958
- * alone rather than crash mid-session.
23663
+ * This is how the selected-row text stays legible across every theme:
23664
+ * coco controls the selection *background* but not the user's terminal
23665
+ * default foreground, so it must supply its own contrasting foreground
23666
+ * instead of hoping the terminal's happens to contrast. Returns
23667
+ * `undefined` for non-hex backgrounds (let the caller leave color alone).
23959
23668
  */
23960
- function withPoppedRepoFrame(state) {
23961
- if (state.repoStack.length <= 1) {
23962
- return { ...state, pendingKey: undefined };
23963
- }
23964
- const topFrame = state.repoStack[state.repoStack.length - 1];
23965
- const ret = topFrame.parentReturn;
23966
- const repoStack = state.repoStack.slice(0, -1);
23967
- if (!ret) {
23968
- return { ...state, repoStack, pendingKey: undefined };
23969
- }
23970
- return {
23971
- ...state,
23972
- repoStack,
23973
- activeView: ret.activeView,
23974
- viewStack: [ret.activeView],
23975
- selectedIndex: ret.selectedIndex,
23976
- selectedFileIndex: ret.selectedFileIndex,
23977
- selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
23978
- filter: ret.filter,
23979
- filterMode: false,
23980
- pendingCommitFocused: false,
23981
- // #995 — restore sidebar tab + sort preferences from the captured
23982
- // parentReturn. Without this, the submodule's tab / sort choice
23983
- // bleeds back into the parent after pop: the user picks 'tags' in
23984
- // a vendored submodule, pops back to the parent, and finds the
23985
- // parent's previously-selected 'branches' tab quietly replaced.
23986
- sidebarTab: ret.sidebarTab,
23987
- userSidebarTab: ret.userSidebarTab,
23988
- branchSort: ret.branchSort,
23989
- tagSort: ret.tagSort,
23990
- pendingKey: undefined,
23991
- pendingConfirmationId: undefined,
23992
- pendingConfirmationPayload: undefined,
23993
- pendingMutationConfirmation: undefined,
23994
- };
23669
+ function readableForegroundFor(bg) {
23670
+ if (!bg)
23671
+ return undefined;
23672
+ const luminance = relativeLuminance(bg);
23673
+ if (luminance === null)
23674
+ return undefined;
23675
+ return luminance > 0.179 ? '#000000' : '#ffffff';
23995
23676
  }
23996
- function withReplacedView(state, value) {
23997
- if (topOfStack(state.viewStack) === value) {
23998
- return { ...state, pendingKey: undefined };
23999
- }
24000
- const viewStack = [...state.viewStack.slice(0, -1), value];
24001
- return {
24002
- ...state,
24003
- activeView: value,
24004
- viewStack,
24005
- worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
24006
- selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24007
- diffSource: value === 'diff' ? state.diffSource : undefined,
24008
- stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
24009
- compareHead: value === 'diff' ? state.compareHead : undefined,
24010
- pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24011
- statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24012
- pendingKey: undefined,
24013
- };
23677
+
23678
+ const THEME_PRESET_COLORS = {
23679
+ default: {
23680
+ accent: 'cyan',
23681
+ border: 'gray',
23682
+ danger: 'red',
23683
+ focusBorder: 'cyan',
23684
+ gitAdded: 'green',
23685
+ gitDeleted: 'red',
23686
+ gitModified: 'yellow',
23687
+ info: 'blue',
23688
+ muted: 'gray',
23689
+ selection: '#1a3a4a',
23690
+ success: 'green',
23691
+ warning: 'yellow',
23692
+ },
23693
+ catppuccin: {
23694
+ accent: '#89b4fa',
23695
+ border: '#585b70',
23696
+ danger: '#f38ba8',
23697
+ focusBorder: '#89dceb',
23698
+ gitAdded: '#a6e3a1',
23699
+ gitDeleted: '#f38ba8',
23700
+ gitModified: '#f9e2af',
23701
+ info: '#89b4fa',
23702
+ muted: '#6c7086',
23703
+ selection: '#45475a',
23704
+ success: '#a6e3a1',
23705
+ warning: '#f9e2af',
23706
+ },
23707
+ gruvbox: {
23708
+ accent: '#83a598',
23709
+ border: '#665c54',
23710
+ danger: '#fb4934',
23711
+ focusBorder: '#8ec07c',
23712
+ gitAdded: '#b8bb26',
23713
+ gitDeleted: '#fb4934',
23714
+ gitModified: '#fabd2f',
23715
+ info: '#83a598',
23716
+ muted: '#928374',
23717
+ selection: '#504945',
23718
+ success: '#b8bb26',
23719
+ warning: '#fabd2f',
23720
+ },
23721
+ dracula: {
23722
+ accent: '#bd93f9',
23723
+ border: '#44475a',
23724
+ danger: '#ff5555',
23725
+ focusBorder: '#ff79c6',
23726
+ gitAdded: '#50fa7b',
23727
+ gitDeleted: '#ff5555',
23728
+ gitModified: '#f1fa8c',
23729
+ info: '#8be9fd',
23730
+ muted: '#6272a4',
23731
+ selection: '#44475a',
23732
+ success: '#50fa7b',
23733
+ warning: '#f1fa8c',
23734
+ },
23735
+ nord: {
23736
+ accent: '#88c0d0',
23737
+ border: '#3b4252',
23738
+ danger: '#bf616a',
23739
+ focusBorder: '#81a1c1',
23740
+ gitAdded: '#a3be8c',
23741
+ gitDeleted: '#bf616a',
23742
+ gitModified: '#ebcb8b',
23743
+ info: '#81a1c1',
23744
+ muted: '#4c566a',
23745
+ selection: '#3b4252',
23746
+ success: '#a3be8c',
23747
+ warning: '#ebcb8b',
23748
+ },
23749
+ 'solarized-dark': {
23750
+ accent: '#268bd2',
23751
+ border: '#073642',
23752
+ danger: '#dc322f',
23753
+ focusBorder: '#2aa198',
23754
+ gitAdded: '#859900',
23755
+ gitDeleted: '#dc322f',
23756
+ gitModified: '#b58900',
23757
+ info: '#268bd2',
23758
+ muted: '#586e75',
23759
+ selection: '#073642',
23760
+ success: '#859900',
23761
+ warning: '#b58900',
23762
+ },
23763
+ 'tokyo-night': {
23764
+ accent: '#7aa2f7',
23765
+ border: '#3b4261',
23766
+ danger: '#f7768e',
23767
+ focusBorder: '#7dcfff',
23768
+ gitAdded: '#9ece6a',
23769
+ gitDeleted: '#f7768e',
23770
+ gitModified: '#e0af68',
23771
+ info: '#7aa2f7',
23772
+ muted: '#565f89',
23773
+ selection: '#33467c',
23774
+ success: '#9ece6a',
23775
+ warning: '#e0af68',
23776
+ },
23777
+ 'one-dark': {
23778
+ accent: '#61afef',
23779
+ border: '#3e4452',
23780
+ danger: '#e06c75',
23781
+ focusBorder: '#56b6c2',
23782
+ gitAdded: '#98c379',
23783
+ gitDeleted: '#e06c75',
23784
+ gitModified: '#e5c07b',
23785
+ info: '#61afef',
23786
+ muted: '#5c6370',
23787
+ selection: '#3e4452',
23788
+ success: '#98c379',
23789
+ warning: '#e5c07b',
23790
+ },
23791
+ 'rose-pine': {
23792
+ accent: '#c4a7e7',
23793
+ border: '#26233a',
23794
+ danger: '#eb6f92',
23795
+ focusBorder: '#9ccfd8',
23796
+ gitAdded: '#31748f',
23797
+ gitDeleted: '#eb6f92',
23798
+ gitModified: '#f6c177',
23799
+ info: '#9ccfd8',
23800
+ muted: '#6e6a86',
23801
+ selection: '#2a273f',
23802
+ success: '#31748f',
23803
+ warning: '#f6c177',
23804
+ },
23805
+ kanagawa: {
23806
+ accent: '#7e9cd8',
23807
+ border: '#2a2a37',
23808
+ danger: '#e82424',
23809
+ focusBorder: '#7fb4ca',
23810
+ gitAdded: '#76946a',
23811
+ gitDeleted: '#e82424',
23812
+ gitModified: '#dca561',
23813
+ info: '#7e9cd8',
23814
+ muted: '#727169',
23815
+ selection: '#2d4f67',
23816
+ success: '#76946a',
23817
+ warning: '#dca561',
23818
+ },
23819
+ everforest: {
23820
+ accent: '#a7c080',
23821
+ border: '#374145',
23822
+ danger: '#e67e80',
23823
+ focusBorder: '#83c092',
23824
+ gitAdded: '#a7c080',
23825
+ gitDeleted: '#e67e80',
23826
+ gitModified: '#dbbc7f',
23827
+ info: '#7fbbb3',
23828
+ muted: '#859289',
23829
+ selection: '#374145',
23830
+ success: '#a7c080',
23831
+ warning: '#dbbc7f',
23832
+ },
23833
+ monokai: {
23834
+ accent: '#66d9ef',
23835
+ border: '#49483e',
23836
+ danger: '#f92672',
23837
+ focusBorder: '#a6e22e',
23838
+ gitAdded: '#a6e22e',
23839
+ gitDeleted: '#f92672',
23840
+ gitModified: '#e6db74',
23841
+ info: '#66d9ef',
23842
+ muted: '#75715e',
23843
+ selection: '#49483e',
23844
+ success: '#a6e22e',
23845
+ warning: '#e6db74',
23846
+ },
23847
+ synthwave: {
23848
+ accent: '#f97e72',
23849
+ border: '#34294f',
23850
+ danger: '#fe4450',
23851
+ focusBorder: '#36f9f6',
23852
+ gitAdded: '#72f1b8',
23853
+ gitDeleted: '#fe4450',
23854
+ gitModified: '#fede5d',
23855
+ info: '#36f9f6',
23856
+ muted: '#848bbd',
23857
+ selection: '#34294f',
23858
+ success: '#72f1b8',
23859
+ warning: '#fede5d',
23860
+ },
23861
+ 'ayu-dark': {
23862
+ accent: '#e6b450',
23863
+ border: '#11151c',
23864
+ danger: '#f07178',
23865
+ focusBorder: '#39bae6',
23866
+ gitAdded: '#7fd962',
23867
+ gitDeleted: '#f07178',
23868
+ gitModified: '#e6b450',
23869
+ info: '#39bae6',
23870
+ muted: '#565b66',
23871
+ selection: '#1a1f29',
23872
+ success: '#7fd962',
23873
+ warning: '#e6b450',
23874
+ },
23875
+ palenight: {
23876
+ accent: '#82aaff',
23877
+ border: '#3a3f58',
23878
+ danger: '#ff5370',
23879
+ focusBorder: '#89ddff',
23880
+ gitAdded: '#c3e88d',
23881
+ gitDeleted: '#ff5370',
23882
+ gitModified: '#ffcb6b',
23883
+ info: '#82aaff',
23884
+ muted: '#676e95',
23885
+ selection: '#3a3f58',
23886
+ success: '#c3e88d',
23887
+ warning: '#ffcb6b',
23888
+ },
23889
+ 'github-dark': {
23890
+ accent: '#58a6ff',
23891
+ border: '#30363d',
23892
+ danger: '#f85149',
23893
+ focusBorder: '#58a6ff',
23894
+ gitAdded: '#3fb950',
23895
+ gitDeleted: '#f85149',
23896
+ gitModified: '#d29922',
23897
+ info: '#58a6ff',
23898
+ muted: '#8b949e',
23899
+ selection: '#264f78',
23900
+ success: '#3fb950',
23901
+ warning: '#d29922',
23902
+ },
23903
+ horizon: {
23904
+ accent: '#e95678',
23905
+ border: '#2e303e',
23906
+ danger: '#e95678',
23907
+ focusBorder: '#25b0bc',
23908
+ gitAdded: '#09f7a0',
23909
+ gitDeleted: '#e95678',
23910
+ gitModified: '#fab795',
23911
+ info: '#25b0bc',
23912
+ muted: '#6c6f93',
23913
+ selection: '#2e303e',
23914
+ success: '#09f7a0',
23915
+ warning: '#fab795',
23916
+ },
23917
+ nightfox: {
23918
+ accent: '#719cd6',
23919
+ border: '#2b3b51',
23920
+ danger: '#c94f6d',
23921
+ focusBorder: '#63cdcf',
23922
+ gitAdded: '#81b29a',
23923
+ gitDeleted: '#c94f6d',
23924
+ gitModified: '#dbc074',
23925
+ info: '#719cd6',
23926
+ muted: '#738091',
23927
+ selection: '#2b3b51',
23928
+ success: '#81b29a',
23929
+ warning: '#dbc074',
23930
+ },
23931
+ carbonfox: {
23932
+ accent: '#78a9ff',
23933
+ border: '#353535',
23934
+ danger: '#ee5396',
23935
+ focusBorder: '#33b1ff',
23936
+ gitAdded: '#42be65',
23937
+ gitDeleted: '#ee5396',
23938
+ gitModified: '#ffe97b',
23939
+ info: '#78a9ff',
23940
+ muted: '#7b7c7e',
23941
+ selection: '#353535',
23942
+ success: '#42be65',
23943
+ warning: '#ffe97b',
23944
+ },
23945
+ 'tokyonight-storm': {
23946
+ accent: '#7aa2f7',
23947
+ border: '#2f334d',
23948
+ danger: '#f7768e',
23949
+ focusBorder: '#2ac3de',
23950
+ gitAdded: '#9ece6a',
23951
+ gitDeleted: '#f7768e',
23952
+ gitModified: '#e0af68',
23953
+ info: '#2ac3de',
23954
+ muted: '#545c7e',
23955
+ selection: '#2f334d',
23956
+ success: '#9ece6a',
23957
+ warning: '#e0af68',
23958
+ },
23959
+ 'catppuccin-latte': {
23960
+ accent: '#1e66f5',
23961
+ border: '#ccd0da',
23962
+ danger: '#d20f39',
23963
+ focusBorder: '#179299',
23964
+ gitAdded: '#40a02b',
23965
+ gitDeleted: '#d20f39',
23966
+ gitModified: '#df8e1d',
23967
+ info: '#1e66f5',
23968
+ muted: '#9ca0b0',
23969
+ selection: '#ccd0da',
23970
+ success: '#40a02b',
23971
+ warning: '#df8e1d',
23972
+ },
23973
+ 'solarized-light': {
23974
+ accent: '#268bd2',
23975
+ border: '#eee8d5',
23976
+ danger: '#dc322f',
23977
+ focusBorder: '#2aa198',
23978
+ gitAdded: '#859900',
23979
+ gitDeleted: '#dc322f',
23980
+ gitModified: '#b58900',
23981
+ info: '#268bd2',
23982
+ muted: '#93a1a1',
23983
+ selection: '#eee8d5',
23984
+ success: '#859900',
23985
+ warning: '#b58900',
23986
+ },
23987
+ 'github-light': {
23988
+ accent: '#0969da',
23989
+ border: '#d0d7de',
23990
+ danger: '#cf222e',
23991
+ focusBorder: '#0969da',
23992
+ gitAdded: '#1a7f37',
23993
+ gitDeleted: '#cf222e',
23994
+ gitModified: '#9a6700',
23995
+ info: '#0969da',
23996
+ muted: '#656d76',
23997
+ selection: '#ddf4ff',
23998
+ success: '#1a7f37',
23999
+ warning: '#9a6700',
24000
+ },
24001
+ iceberg: {
24002
+ accent: '#84a0c6',
24003
+ border: '#1e2132',
24004
+ danger: '#e27878',
24005
+ focusBorder: '#89b8c2',
24006
+ gitAdded: '#b4be82',
24007
+ gitDeleted: '#e27878',
24008
+ gitModified: '#e2a478',
24009
+ info: '#84a0c6',
24010
+ muted: '#6b7089',
24011
+ selection: '#1e2132',
24012
+ success: '#b4be82',
24013
+ warning: '#e2a478',
24014
+ },
24015
+ 'material-ocean': {
24016
+ accent: '#82aaff',
24017
+ border: '#2b2f3a',
24018
+ danger: '#f07178',
24019
+ focusBorder: '#89ddff',
24020
+ gitAdded: '#c3e88d',
24021
+ gitDeleted: '#f07178',
24022
+ gitModified: '#ffcb6b',
24023
+ info: '#82aaff',
24024
+ muted: '#464b5d',
24025
+ selection: '#2b2f3a',
24026
+ success: '#c3e88d',
24027
+ warning: '#ffcb6b',
24028
+ },
24029
+ moonlight: {
24030
+ accent: '#82aaff',
24031
+ border: '#2f334d',
24032
+ danger: '#ff757f',
24033
+ focusBorder: '#86e1fc',
24034
+ gitAdded: '#c3e88d',
24035
+ gitDeleted: '#ff757f',
24036
+ gitModified: '#ffc777',
24037
+ info: '#82aaff',
24038
+ muted: '#636da6',
24039
+ selection: '#2f334d',
24040
+ success: '#c3e88d',
24041
+ warning: '#ffc777',
24042
+ },
24043
+ poimandres: {
24044
+ accent: '#add7ff',
24045
+ border: '#1b1e28',
24046
+ danger: '#d0679d',
24047
+ focusBorder: '#5de4c7',
24048
+ gitAdded: '#5de4c7',
24049
+ gitDeleted: '#d0679d',
24050
+ gitModified: '#fffac2',
24051
+ info: '#add7ff',
24052
+ muted: '#506477',
24053
+ selection: '#1b1e28',
24054
+ success: '#5de4c7',
24055
+ warning: '#fffac2',
24056
+ },
24057
+ 'vitesse-dark': {
24058
+ accent: '#4d9375',
24059
+ border: '#282828',
24060
+ danger: '#cb7676',
24061
+ focusBorder: '#4d9375',
24062
+ gitAdded: '#4d9375',
24063
+ gitDeleted: '#cb7676',
24064
+ gitModified: '#e6cc77',
24065
+ info: '#6394bf',
24066
+ muted: '#758575',
24067
+ selection: '#282828',
24068
+ success: '#4d9375',
24069
+ warning: '#e6cc77',
24070
+ },
24071
+ vesper: {
24072
+ accent: '#ffc799',
24073
+ border: '#232323',
24074
+ danger: '#f5a191',
24075
+ focusBorder: '#99ffe4',
24076
+ gitAdded: '#99ffe4',
24077
+ gitDeleted: '#f5a191',
24078
+ gitModified: '#ffc799',
24079
+ info: '#a0c4ff',
24080
+ muted: '#575757',
24081
+ selection: '#232323',
24082
+ success: '#99ffe4',
24083
+ warning: '#ffc799',
24084
+ },
24085
+ flexoki: {
24086
+ accent: '#205ea6',
24087
+ border: '#343331',
24088
+ danger: '#af3029',
24089
+ focusBorder: '#24837b',
24090
+ gitAdded: '#66800b',
24091
+ gitDeleted: '#af3029',
24092
+ gitModified: '#ad8301',
24093
+ info: '#205ea6',
24094
+ muted: '#878580',
24095
+ selection: '#343331',
24096
+ success: '#66800b',
24097
+ warning: '#ad8301',
24098
+ },
24099
+ mellow: {
24100
+ accent: '#7eb8da',
24101
+ border: '#2a2a2a',
24102
+ danger: '#f5a191',
24103
+ focusBorder: '#a3d4a0',
24104
+ gitAdded: '#a3d4a0',
24105
+ gitDeleted: '#f5a191',
24106
+ gitModified: '#f0c674',
24107
+ info: '#7eb8da',
24108
+ muted: '#6b6b6b',
24109
+ selection: '#2a2a2a',
24110
+ success: '#a3d4a0',
24111
+ warning: '#f0c674',
24112
+ },
24113
+ 'night-owl': {
24114
+ accent: '#82aaff',
24115
+ border: '#1d3b53',
24116
+ danger: '#ef5350',
24117
+ focusBorder: '#7fdbca',
24118
+ gitAdded: '#addb67',
24119
+ gitDeleted: '#ef5350',
24120
+ gitModified: '#ecc48d',
24121
+ info: '#82aaff',
24122
+ muted: '#637777',
24123
+ selection: '#1d3b53',
24124
+ success: '#addb67',
24125
+ warning: '#ecc48d',
24126
+ },
24127
+ cobalt2: {
24128
+ accent: '#ffc600',
24129
+ border: '#234e6d',
24130
+ danger: '#ff628c',
24131
+ focusBorder: '#9effff',
24132
+ gitAdded: '#3ad900',
24133
+ gitDeleted: '#ff628c',
24134
+ gitModified: '#ffc600',
24135
+ info: '#9effff',
24136
+ muted: '#627e99',
24137
+ selection: '#0d3a58',
24138
+ success: '#3ad900',
24139
+ warning: '#ffc600',
24140
+ },
24141
+ 'oceanic-next': {
24142
+ accent: '#6699cc',
24143
+ border: '#343d46',
24144
+ danger: '#ec5f67',
24145
+ focusBorder: '#5fb3b3',
24146
+ gitAdded: '#99c794',
24147
+ gitDeleted: '#ec5f67',
24148
+ gitModified: '#fac863',
24149
+ info: '#6699cc',
24150
+ muted: '#65737e',
24151
+ selection: '#4f5b66',
24152
+ success: '#99c794',
24153
+ warning: '#fac863',
24154
+ },
24155
+ 'catppuccin-macchiato': {
24156
+ accent: '#8aadf4',
24157
+ border: '#494d64',
24158
+ danger: '#ed8796',
24159
+ focusBorder: '#91d7e3',
24160
+ gitAdded: '#a6da95',
24161
+ gitDeleted: '#ed8796',
24162
+ gitModified: '#eed49f',
24163
+ info: '#8aadf4',
24164
+ muted: '#6e738d',
24165
+ selection: '#363a4f',
24166
+ success: '#a6da95',
24167
+ warning: '#eed49f',
24168
+ },
24169
+ 'gruvbox-light': {
24170
+ accent: '#076678',
24171
+ border: '#bdae93',
24172
+ danger: '#9d0006',
24173
+ focusBorder: '#427b58',
24174
+ gitAdded: '#79740e',
24175
+ gitDeleted: '#9d0006',
24176
+ gitModified: '#b57614',
24177
+ info: '#076678',
24178
+ muted: '#7c6f64',
24179
+ selection: '#ebdbb2',
24180
+ success: '#79740e',
24181
+ warning: '#b57614',
24182
+ },
24183
+ 'tokyo-night-day': {
24184
+ accent: '#2e7de9',
24185
+ border: '#b7c1e3',
24186
+ danger: '#f52a65',
24187
+ focusBorder: '#007197',
24188
+ gitAdded: '#587539',
24189
+ gitDeleted: '#f52a65',
24190
+ gitModified: '#8c6c3e',
24191
+ info: '#2e7de9',
24192
+ muted: '#848cb5',
24193
+ selection: '#b7c1e3',
24194
+ success: '#587539',
24195
+ warning: '#8c6c3e',
24196
+ },
24197
+ 'one-light': {
24198
+ accent: '#4078f2',
24199
+ border: '#d4d4d4',
24200
+ danger: '#e45649',
24201
+ focusBorder: '#0184bc',
24202
+ gitAdded: '#50a14f',
24203
+ gitDeleted: '#e45649',
24204
+ gitModified: '#c18401',
24205
+ info: '#4078f2',
24206
+ muted: '#a0a1a7',
24207
+ selection: '#e5e5e6',
24208
+ success: '#50a14f',
24209
+ warning: '#c18401',
24210
+ },
24211
+ 'ayu-light': {
24212
+ accent: '#fa8d3e',
24213
+ border: '#e6e6e6',
24214
+ danger: '#e65050',
24215
+ focusBorder: '#4cbf99',
24216
+ gitAdded: '#6cbf43',
24217
+ gitDeleted: '#e65050',
24218
+ gitModified: '#f2ae49',
24219
+ info: '#399ee6',
24220
+ muted: '#abb0b6',
24221
+ selection: '#d1e4f4',
24222
+ success: '#6cbf43',
24223
+ warning: '#f2ae49',
24224
+ },
24225
+ 'rose-pine-dawn': {
24226
+ accent: '#907aa9',
24227
+ border: '#dfdad9',
24228
+ danger: '#b4637a',
24229
+ focusBorder: '#56949f',
24230
+ gitAdded: '#286983',
24231
+ gitDeleted: '#b4637a',
24232
+ gitModified: '#ea9d34',
24233
+ info: '#56949f',
24234
+ muted: '#9893a5',
24235
+ selection: '#dfdad9',
24236
+ success: '#286983',
24237
+ warning: '#ea9d34',
24238
+ },
24239
+ 'everforest-light': {
24240
+ accent: '#8da101',
24241
+ border: '#ddd8be',
24242
+ danger: '#f85552',
24243
+ focusBorder: '#35a77c',
24244
+ gitAdded: '#8da101',
24245
+ gitDeleted: '#f85552',
24246
+ gitModified: '#dfa000',
24247
+ info: '#3a94c5',
24248
+ muted: '#939f91',
24249
+ selection: '#edeada',
24250
+ success: '#8da101',
24251
+ warning: '#dfa000',
24252
+ },
24253
+ 'vitesse-light': {
24254
+ accent: '#1e754f',
24255
+ border: '#e0e0e0',
24256
+ danger: '#ab5959',
24257
+ focusBorder: '#2993a3',
24258
+ gitAdded: '#1e754f',
24259
+ gitDeleted: '#ab5959',
24260
+ gitModified: '#b07d48',
24261
+ info: '#296aa3',
24262
+ muted: '#999fa6',
24263
+ selection: '#eaeaeb',
24264
+ success: '#1e754f',
24265
+ warning: '#b07d48',
24266
+ },
24267
+ dayfox: {
24268
+ accent: '#2848a9',
24269
+ border: '#e4dcd4',
24270
+ danger: '#a5222f',
24271
+ focusBorder: '#287980',
24272
+ gitAdded: '#396847',
24273
+ gitDeleted: '#a5222f',
24274
+ gitModified: '#ac5402',
24275
+ info: '#2848a9',
24276
+ muted: '#908479',
24277
+ selection: '#e7d2be',
24278
+ success: '#396847',
24279
+ warning: '#ac5402',
24280
+ },
24281
+ 'night-owl-light': {
24282
+ accent: '#288ed7',
24283
+ border: '#d9d9d9',
24284
+ danger: '#d3423e',
24285
+ focusBorder: '#2aa298',
24286
+ gitAdded: '#08916a',
24287
+ gitDeleted: '#d3423e',
24288
+ gitModified: '#daaa01',
24289
+ info: '#288ed7',
24290
+ muted: '#989fb1',
24291
+ selection: '#e4e8f0',
24292
+ success: '#08916a',
24293
+ warning: '#daaa01',
24294
+ },
24295
+ 'flexoki-light': {
24296
+ accent: '#205ea6',
24297
+ border: '#cecdc3',
24298
+ danger: '#af3029',
24299
+ focusBorder: '#24837b',
24300
+ gitAdded: '#66800b',
24301
+ gitDeleted: '#af3029',
24302
+ gitModified: '#ad8301',
24303
+ info: '#205ea6',
24304
+ muted: '#6f6e69',
24305
+ selection: '#e6e4d9',
24306
+ success: '#66800b',
24307
+ warning: '#ad8301',
24308
+ },
24309
+ 'material-lighter': {
24310
+ accent: '#39adb5',
24311
+ border: '#e7eaec',
24312
+ danger: '#e53935',
24313
+ focusBorder: '#39adb5',
24314
+ gitAdded: '#91b859',
24315
+ gitDeleted: '#e53935',
24316
+ gitModified: '#f6a434',
24317
+ info: '#6182b8',
24318
+ muted: '#90a4ae',
24319
+ selection: '#d3e1e8',
24320
+ success: '#91b859',
24321
+ warning: '#f6a434',
24322
+ },
24323
+ 'papercolor-light': {
24324
+ accent: '#0087af',
24325
+ border: '#d7d7d7',
24326
+ danger: '#af0000',
24327
+ focusBorder: '#005f87',
24328
+ gitAdded: '#008700',
24329
+ gitDeleted: '#af0000',
24330
+ gitModified: '#d75f00',
24331
+ info: '#0087af',
24332
+ muted: '#878787',
24333
+ selection: '#d0d0d0',
24334
+ success: '#008700',
24335
+ warning: '#d75f00',
24336
+ },
24337
+ 'modus-operandi': {
24338
+ accent: '#0031a9',
24339
+ border: '#d7d7d7',
24340
+ danger: '#a60000',
24341
+ focusBorder: '#005e8b',
24342
+ gitAdded: '#006800',
24343
+ gitDeleted: '#a60000',
24344
+ gitModified: '#6f5500',
24345
+ info: '#0031a9',
24346
+ muted: '#595959',
24347
+ selection: '#c0deff',
24348
+ success: '#006800',
24349
+ warning: '#6f5500',
24350
+ },
24351
+ 'quiet-light': {
24352
+ accent: '#4b83cd',
24353
+ border: '#e0e0e0',
24354
+ danger: '#aa3731',
24355
+ focusBorder: '#4b83cd',
24356
+ gitAdded: '#448c27',
24357
+ gitDeleted: '#aa3731',
24358
+ gitModified: '#a67d00',
24359
+ info: '#4b83cd',
24360
+ muted: '#a3a6ad',
24361
+ selection: '#c9d0d9',
24362
+ success: '#448c27',
24363
+ warning: '#a67d00',
24364
+ },
24365
+ };
24366
+ /**
24367
+ * Ordered list of every selectable theme preset, for the `coco ui` theme
24368
+ * picker and any UI that enumerates themes. `monochrome` isn't a key in
24369
+ * `THEME_PRESET_COLORS` (it's handled via `noColor`), so it's spliced in
24370
+ * right after `default` — the two non-color baselines sit together at the
24371
+ * top, followed by the color themes in catalog order.
24372
+ */
24373
+ function getLogInkThemePresets() {
24374
+ const keys = Object.keys(THEME_PRESET_COLORS);
24375
+ const [first, ...rest] = keys;
24376
+ return first === 'default'
24377
+ ? ['default', 'monochrome', ...rest]
24378
+ : ['monochrome', ...keys];
24014
24379
  }
24015
- function withFilter(state, filter, promotedSelections) {
24016
- const filteredCommits = filterCommits(state.commits, filter);
24017
- // P4.5: rectify promoted-view selections when the filter changes. Prefer
24018
- // the runtime-supplied snapshot — which preserves the cursor on the same
24019
- // item when it's still in the filtered list and only snaps to result[0]
24020
- // when the previously-selected item dropped out. Falls back to the older
24021
- // "snap to 0" behavior when no snapshot was provided (test paths,
24022
- // dispatchers without context).
24023
- const filterChanged = state.filter !== filter;
24024
- const branchIndex = promotedSelections?.branchIndex ??
24025
- (filterChanged ? 0 : state.selectedBranchIndex);
24026
- const tagIndex = promotedSelections?.tagIndex ??
24027
- (filterChanged ? 0 : state.selectedTagIndex);
24028
- const stashIndex = promotedSelections?.stashIndex ??
24029
- (filterChanged ? 0 : state.selectedStashIndex);
24030
- // Reflog (#781) snaps to 0 on filter change rather than rectifying.
24031
- // The list is chronological and the user is unlikely to be tracking
24032
- // a specific entry through filter changes — the simpler reset
24033
- // matches the "find recovery target by typing" interaction.
24034
- const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
24035
- return {
24036
- ...state,
24037
- filter,
24038
- filteredCommits,
24039
- selectedIndex: clampIndex(state.selectedIndex, filteredCommits.length),
24040
- selectedFileIndex: 0,
24041
- selectedBranchIndex: branchIndex,
24042
- selectedTagIndex: tagIndex,
24043
- selectedStashIndex: stashIndex,
24044
- selectedReflogIndex: reflogIndex,
24045
- diffPreviewOffset: 0,
24046
- pendingKey: undefined,
24047
- };
24380
+ function shouldUseAscii(term) {
24381
+ if (!term) {
24382
+ return false;
24383
+ }
24384
+ return term === 'dumb' || term.startsWith('vt100');
24048
24385
  }
24049
- function replaceRows(state, rows) {
24050
- // Wholesale row replacement after a server-side re-fetch (#776).
24051
- // Resets the cursor to the top because the new commit set may not
24052
- // share any hashes with the old one (e.g. switching from `--all` to
24053
- // `-- some/path` typically dumps the previous selection).
24054
- const commits = getCommitRows(rows);
24055
- const filteredCommits = filterCommits(commits, state.filter);
24386
+ function createLogInkTheme(options = {}) {
24387
+ const noColor = (options.noColor ?? Boolean(process.env.NO_COLOR)) ||
24388
+ options.preset === 'monochrome';
24389
+ const ascii = options.ascii ?? shouldUseAscii(options.term ?? process.env.TERM);
24390
+ const requestedPreset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
24391
+ // P5.2 gracefully downgrade hex presets (catppuccin / gruvbox) when
24392
+ // the host terminal can't render truecolor. Chalk approximates hex in
24393
+ // those modes anyway, but the default preset's ANSI-named palette
24394
+ // renders far more faithfully on 16-color terminals.
24395
+ const colorLevel = getColorLevel(options.env ?? process.env);
24396
+ const preset = !noColor && presetUsesTrueColor(requestedPreset) && colorLevel !== 'truecolor'
24397
+ ? 'default'
24398
+ : requestedPreset;
24399
+ const colors = noColor
24400
+ ? {}
24401
+ : {
24402
+ ...THEME_PRESET_COLORS[preset],
24403
+ // Preserve the requested theme's selection background even when the
24404
+ // rest of the palette downgrades to `default`. The selection is a
24405
+ // single background color the terminal can approximate; without this,
24406
+ // a light theme inherits `default`'s dark selection (#1a3a4a) and the
24407
+ // selected row renders as a dark bar on a light background.
24408
+ ...(preset !== requestedPreset && THEME_PRESET_COLORS[requestedPreset]?.selection
24409
+ ? { selection: THEME_PRESET_COLORS[requestedPreset].selection }
24410
+ : {}),
24411
+ ...options.colors,
24412
+ };
24413
+ // Derive a contrasting foreground for the selected row from its own
24414
+ // selection background, unless the caller supplied one explicitly. coco
24415
+ // owns the selection background but not the terminal's default foreground,
24416
+ // so without this the selected row's text falls back to whatever the
24417
+ // user's terminal foreground is — which may not contrast with the bar at
24418
+ // all (the bug behind unreadable selected rows on many themes).
24419
+ if (!noColor && colors.selection && !colors.selectionForeground) {
24420
+ const selectionForeground = readableForegroundFor(colors.selection);
24421
+ if (selectionForeground) {
24422
+ colors.selectionForeground = selectionForeground;
24423
+ }
24424
+ }
24056
24425
  return {
24057
- ...state,
24058
- rows,
24059
- commits,
24060
- filteredCommits,
24061
- selectedIndex: 0,
24062
- selectedFileIndex: 0,
24063
- pendingCommitFocused: false,
24064
- pendingKey: undefined,
24065
- // Rows just landed — clear the boot-loading flag so the history
24066
- // surface drops the "Loading commits…" placeholder. Safe to clear
24067
- // unconditionally because `replaceRows` only fires after a real
24068
- // git log returns.
24069
- bootLoading: false,
24426
+ noColor,
24427
+ ascii,
24428
+ borderStyle: options.borderStyle || (ascii ? 'classic' : 'round'),
24429
+ colors,
24070
24430
  };
24071
24431
  }
24072
- function appendRows(state, rows) {
24073
- const selected = getSelectedInkCommit(state);
24074
- const nextRows = [...state.rows, ...rows];
24075
- const seen = new Set();
24076
- const commits = getCommitRows(nextRows).filter((commit) => {
24077
- if (seen.has(commit.hash)) {
24078
- return false;
24432
+
24433
+ /**
24434
+ * Canned filter presets for the issue / PR triage TUI views
24435
+ * (#882 phase 6). Each preset compiles to the same shape the
24436
+ * underlying list fetchers (`getIssueList` / `getPullRequestList`)
24437
+ * already accept — there's no new `gh` surface area, just a
24438
+ * curated set of common triage angles surfaced as a single
24439
+ * keystroke (`f` cycles).
24440
+ *
24441
+ * The presets are deliberately *not* a 1:1 mirror across the two
24442
+ * surfaces:
24443
+ *
24444
+ * - Issues have no draft / mergeable concept, so `draft` /
24445
+ * `mergeable` are skipped on the issue list.
24446
+ * - PRs have a `merged` state distinct from `closed`; issues
24447
+ * don't.
24448
+ * - `mine` semantics differ subtly: for issues it tends to
24449
+ * mean "I'm the assignee" (issues are tasks people pick up);
24450
+ * for PRs it means "I'm the author" (PRs are work people
24451
+ * post). The presets bake those in so the user doesn't have
24452
+ * to think about it.
24453
+ */
24454
+ /** Cycle order — must match the keystroke walk on `f`. */
24455
+ const ISSUE_FILTER_PRESETS = [
24456
+ 'open',
24457
+ 'closed',
24458
+ 'mine',
24459
+ 'assigned',
24460
+ ];
24461
+ const PULL_REQUEST_FILTER_PRESETS = [
24462
+ 'open',
24463
+ 'draft',
24464
+ 'mine',
24465
+ 'assigned',
24466
+ 'closed',
24467
+ 'merged',
24468
+ ];
24469
+ const ISSUE_FILTER_LABELS = {
24470
+ open: 'open',
24471
+ closed: 'closed',
24472
+ mine: 'mine (assigned)',
24473
+ assigned: 'assigned to me',
24474
+ };
24475
+ const PULL_REQUEST_FILTER_LABELS = {
24476
+ open: 'open',
24477
+ draft: 'draft',
24478
+ mine: 'mine (authored)',
24479
+ assigned: 'assigned to me',
24480
+ closed: 'closed',
24481
+ merged: 'merged',
24482
+ };
24483
+ /**
24484
+ * Resolve a preset to the filter object the data fetcher accepts.
24485
+ * Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
24486
+ * `getPullRequestList` so unit tests can assert the mapping
24487
+ * independently from the fetch pipeline.
24488
+ */
24489
+ function issueFilterForPreset(preset) {
24490
+ switch (preset) {
24491
+ case 'open':
24492
+ return { state: 'open' };
24493
+ case 'closed':
24494
+ return { state: 'closed' };
24495
+ case 'mine':
24496
+ // Issues are tasks — "mine" is what *I'm working on*, i.e.
24497
+ // assigned to me + still open. Same as `assigned` plus the
24498
+ // open-state filter for ergonomic single-keystroke focus on
24499
+ // the active backlog.
24500
+ return { state: 'open', assignee: '@me' };
24501
+ case 'assigned':
24502
+ return { assignee: '@me' };
24503
+ }
24504
+ }
24505
+ function pullRequestFilterForPreset(preset) {
24506
+ switch (preset) {
24507
+ case 'open':
24508
+ return { state: 'open' };
24509
+ case 'draft':
24510
+ // gh's `--draft` flag implies `--state open`; surface that
24511
+ // explicitly so the canonicalize step doesn't elide it.
24512
+ return { state: 'open', draft: true };
24513
+ case 'mine':
24514
+ // PRs are work — "mine" is what *I authored*. Most useful
24515
+ // when looking at one's own backlog of in-flight PRs.
24516
+ return { state: 'open', author: '@me' };
24517
+ case 'assigned':
24518
+ return { assignee: '@me' };
24519
+ case 'closed':
24520
+ return { state: 'closed' };
24521
+ case 'merged':
24522
+ return { state: 'merged' };
24523
+ }
24524
+ }
24525
+ function cycleIssueFilterPreset(current) {
24526
+ const index = ISSUE_FILTER_PRESETS.indexOf(current);
24527
+ const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
24528
+ return ISSUE_FILTER_PRESETS[next];
24529
+ }
24530
+ function cyclePullRequestFilterPreset(current) {
24531
+ const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
24532
+ const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
24533
+ return PULL_REQUEST_FILTER_PRESETS[next];
24534
+ }
24535
+
24536
+ /**
24537
+ * Sort modes for the promoted views (P4.2).
24538
+ *
24539
+ * Pure: takes existing context entries + the active mode, returns a sorted
24540
+ * copy. Tested in isolation; the runtime just calls these helpers.
24541
+ *
24542
+ * Display label uses `▼` (U+25BC) under truecolor / UTF-8 and falls back to
24543
+ * `v` under ASCII. Letters (`recent` / `name` / `ahead`) carry meaning;
24544
+ * shape enhances.
24545
+ */
24546
+ const BRANCH_SORT_MODES = ['name', 'recent', 'ahead'];
24547
+ const DEFAULT_BRANCH_SORT_MODE = 'name';
24548
+ function cycleBranchSort(mode) {
24549
+ const index = BRANCH_SORT_MODES.indexOf(mode);
24550
+ if (index < 0)
24551
+ return BRANCH_SORT_MODES[0];
24552
+ return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
24553
+ }
24554
+ function sortBranches(branches, mode) {
24555
+ // Pin the current branch at index 0 regardless of sort mode (#806
24556
+ // follow-up). Lands the user's cursor on the active branch by
24557
+ // default and keeps the most-relevant row glued to the top of the
24558
+ // list as they cycle sorts.
24559
+ const current = branches.find((entry) => entry.current);
24560
+ const rest = branches.filter((entry) => !entry.current);
24561
+ const sortedRest = rest.slice();
24562
+ switch (mode) {
24563
+ case 'name':
24564
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
24565
+ break;
24566
+ case 'recent':
24567
+ // ISO-shaped dates compare byte-for-byte; descending so the freshest
24568
+ // branch sits at the top.
24569
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
24570
+ a.shortName.localeCompare(b.shortName));
24571
+ break;
24572
+ case 'ahead':
24573
+ // ahead-first; ties broken by behind, then by name. Keeps "this branch
24574
+ // has unmerged work" in the user's first scroll.
24575
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
24576
+ a.shortName.localeCompare(b.shortName));
24577
+ break;
24578
+ }
24579
+ return current ? [current, ...sortedRest] : sortedRest;
24580
+ }
24581
+ const TAG_SORT_MODES = ['recent', 'name'];
24582
+ const DEFAULT_TAG_SORT_MODE = 'recent';
24583
+ function cycleTagSort(mode) {
24584
+ const index = TAG_SORT_MODES.indexOf(mode);
24585
+ if (index < 0)
24586
+ return TAG_SORT_MODES[0];
24587
+ return TAG_SORT_MODES[(index + 1) % TAG_SORT_MODES.length];
24588
+ }
24589
+ function sortTags(tags, mode) {
24590
+ const copy = tags.slice();
24591
+ switch (mode) {
24592
+ case 'name':
24593
+ return copy.sort((a, b) => a.name.localeCompare(b.name));
24594
+ case 'recent':
24595
+ return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
24596
+ a.name.localeCompare(b.name));
24597
+ default:
24598
+ return copy;
24599
+ }
24600
+ }
24601
+ /* ---------------------------- header indicator -------------------------- */
24602
+ function formatSortIndicator(mode, options = {}) {
24603
+ return `${options.ascii ? 'v' : '▼'} ${mode}`;
24604
+ }
24605
+
24606
+ const DEFAULT_CHANGELOG_VIEW_STATE = {
24607
+ status: 'idle',
24608
+ scrollOffset: 0,
24609
+ };
24610
+ const DEFAULT_LOG_INK_STATUS_FILTER_MASK = {
24611
+ staged: true,
24612
+ unstaged: true,
24613
+ untracked: true,
24614
+ };
24615
+ /**
24616
+ * Detect a history server-side filter prefix (#776). Returns the parsed
24617
+ * `LogInkHistoryFetchArgs` for `path:<value>` and `author:<value>`
24618
+ * prefixes, or `undefined` for a plain (client-side) filter. The whole
24619
+ * remainder of the string (post-prefix) becomes the value — paths and
24620
+ * author names commonly contain spaces, and we don't try to parse
24621
+ * shell-like syntax.
24622
+ */
24623
+ function parseLogInkHistoryFetchPrefix(filter) {
24624
+ const trimmed = filter.trim();
24625
+ if (trimmed.startsWith('path:')) {
24626
+ const value = trimmed.slice('path:'.length).trim();
24627
+ return value ? { path: value } : undefined;
24628
+ }
24629
+ if (trimmed.startsWith('author:')) {
24630
+ const value = trimmed.slice('author:'.length).trim();
24631
+ return value ? { author: value } : undefined;
24632
+ }
24633
+ return undefined;
24634
+ }
24635
+ const FOCUS_ORDER = ['sidebar', 'commits', 'detail'];
24636
+ const SIDEBAR_TABS = ['status', 'branches', 'tags', 'stashes', 'worktrees'];
24637
+ function searchableFields(commit) {
24638
+ return [
24639
+ commit.shortHash,
24640
+ commit.hash,
24641
+ commit.date,
24642
+ commit.author,
24643
+ commit.message,
24644
+ ...commit.refs,
24645
+ ];
24646
+ }
24647
+ function scoreField(field, term) {
24648
+ const value = field.toLowerCase();
24649
+ const normalized = term.toLowerCase();
24650
+ if (!normalized) {
24651
+ return 0;
24652
+ }
24653
+ if (value === normalized) {
24654
+ return 1000;
24655
+ }
24656
+ if (value.startsWith(normalized)) {
24657
+ return 800 - Math.min(value.length - normalized.length, 200);
24658
+ }
24659
+ const substringIndex = value.indexOf(normalized);
24660
+ if (substringIndex >= 0) {
24661
+ return 600 - Math.min(substringIndex, 200);
24662
+ }
24663
+ let searchIndex = 0;
24664
+ let distance = 0;
24665
+ for (const character of normalized) {
24666
+ const nextIndex = value.indexOf(character, searchIndex);
24667
+ if (nextIndex < 0) {
24668
+ return undefined;
24079
24669
  }
24080
- seen.add(commit.hash);
24081
- return true;
24082
- });
24083
- const filteredCommits = filterCommits(commits, state.filter);
24084
- const selectedIndex = selected
24085
- ? filteredCommits.findIndex((commit) => commit.hash === selected.hash)
24086
- : state.selectedIndex;
24670
+ distance += nextIndex - searchIndex;
24671
+ searchIndex = nextIndex + 1;
24672
+ }
24673
+ return 300 - Math.min(distance, 200);
24674
+ }
24675
+ function scoreLogInkCommitFilter(commit, filter) {
24676
+ const terms = filter.trim().split(/\s+/).filter(Boolean);
24677
+ if (terms.length === 0) {
24678
+ return 0;
24679
+ }
24680
+ const fields = searchableFields(commit);
24681
+ let score = 0;
24682
+ for (const term of terms) {
24683
+ const bestFieldScore = fields.reduce((best, field) => {
24684
+ const fieldScore = scoreField(field, term);
24685
+ if (fieldScore === undefined) {
24686
+ return best;
24687
+ }
24688
+ return best === undefined ? fieldScore : Math.max(best, fieldScore);
24689
+ }, undefined);
24690
+ if (bestFieldScore === undefined) {
24691
+ return undefined;
24692
+ }
24693
+ score += bestFieldScore;
24694
+ }
24695
+ return score;
24696
+ }
24697
+ function filterCommits(commits, filter) {
24698
+ return commits
24699
+ .map((commit, index) => ({
24700
+ commit,
24701
+ index,
24702
+ score: scoreLogInkCommitFilter(commit, filter),
24703
+ }))
24704
+ .filter((entry) => entry.score !== undefined)
24705
+ .sort((a, b) => b.score - a.score || a.index - b.index)
24706
+ .map((entry) => entry.commit);
24707
+ }
24708
+ function clampIndex(index, length) {
24709
+ if (length === 0) {
24710
+ return 0;
24711
+ }
24712
+ return Math.max(0, Math.min(index, length - 1));
24713
+ }
24714
+ function cycleValue(values, current, delta) {
24715
+ const currentIndex = Math.max(0, values.indexOf(current));
24716
+ const nextIndex = (currentIndex + delta + values.length) % values.length;
24717
+ return values[nextIndex];
24718
+ }
24719
+ const HOME_VIEW = 'history';
24720
+ function topOfStack(stack) {
24721
+ return stack[stack.length - 1];
24722
+ }
24723
+ function withPushedView(state, value) {
24724
+ if (topOfStack(state.viewStack) === value) {
24725
+ return { ...state, pendingKey: undefined };
24726
+ }
24727
+ const viewStack = [...state.viewStack, value];
24087
24728
  return {
24088
24729
  ...state,
24089
- rows: nextRows,
24090
- commits,
24091
- filteredCommits,
24092
- selectedIndex: selectedIndex >= 0
24093
- ? selectedIndex
24094
- : clampIndex(state.selectedIndex, filteredCommits.length),
24730
+ activeView: value,
24731
+ viewStack,
24732
+ // The compose + status views' right detail panels already show
24733
+ // worktree info, so keeping the left sidebar on the Status tab
24734
+ // duplicates that information. Auto-switch to Branches when entering
24735
+ // either view; the user can swap back with [/] if they want.
24736
+ //
24737
+ // We update only the rendered `sidebarTab` here, never
24738
+ // `userSidebarTab`, so this auto-switch is invisible to per-repo
24739
+ // persistence and pop-view restores the previous tab.
24740
+ sidebarTab: value === 'compose' || value === 'status' ? 'branches' : state.sidebarTab,
24741
+ worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
24742
+ selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24743
+ diffSource: value === 'diff' ? state.diffSource : undefined,
24744
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
24745
+ compareHead: value === 'diff' ? state.compareHead : undefined,
24746
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24747
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24095
24748
  pendingKey: undefined,
24096
24749
  };
24097
24750
  }
24098
- function nextHunkOffset(currentOffset, hunkOffsets, delta) {
24099
- if (hunkOffsets.length === 0) {
24100
- return currentOffset;
24101
- }
24102
- if (delta > 0) {
24103
- const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
24104
- return nextOffset === undefined ? currentOffset : nextOffset;
24751
+ function withPoppedView(state) {
24752
+ if (state.viewStack.length <= 1) {
24753
+ return { ...state, pendingKey: undefined };
24105
24754
  }
24106
- const previousOffset = [...hunkOffsets].reverse().find((offset) => offset < currentOffset);
24107
- return previousOffset === undefined ? currentOffset : previousOffset;
24108
- }
24109
- function nextHunkIndex(currentOffset, hunkOffsets, delta) {
24110
- const offset = nextHunkOffset(currentOffset, hunkOffsets, delta);
24111
- return Math.max(0, hunkOffsets.indexOf(offset));
24112
- }
24113
- function getLogInkSidebarTabs() {
24114
- return [...SIDEBAR_TABS];
24755
+ const viewStack = state.viewStack.slice(0, -1);
24756
+ const next = topOfStack(viewStack);
24757
+ // #779 — compareBase is "cleared when the diff view is popped." We
24758
+ // detect that case by checking if the *previous* top was 'diff'.
24759
+ // The compare workflow ends when the user backs out of the compare
24760
+ // diff; on the next mark they re-set the base. Other view pops
24761
+ // preserve compareBase so the user can move between branches / tags /
24762
+ // history while hunting for a head ref.
24763
+ const wasOnDiff = state.activeView === 'diff';
24764
+ return {
24765
+ ...state,
24766
+ activeView: next,
24767
+ viewStack,
24768
+ // Restore the user's last explicit tab choice so popping out of
24769
+ // compose / status (which auto-switch the sidebar to Branches)
24770
+ // returns the user to whatever they actually had open before.
24771
+ sidebarTab: state.userSidebarTab,
24772
+ worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
24773
+ selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24774
+ diffSource: next === 'diff' ? state.diffSource : undefined,
24775
+ stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
24776
+ compareBase: wasOnDiff ? undefined : state.compareBase,
24777
+ compareHead: next === 'diff' ? state.compareHead : undefined,
24778
+ pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
24779
+ statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
24780
+ pendingKey: undefined,
24781
+ };
24115
24782
  }
24116
- function createLogInkState(rows, options = {}) {
24117
- const commits = getCommitRows(rows);
24118
- const initialView = options.activeView || 'history';
24783
+ /**
24784
+ * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
24785
+ * the active view position into the new frame's `parentReturn` so a
24786
+ * subsequent pop lands the user back where they came from, then
24787
+ * resets the per-frame navigation state (active view, view stack,
24788
+ * row / file / submodule cursors, filter) so the nested frame opens
24789
+ * in a clean slate — the mental equivalent of a fresh `coco ui`
24790
+ * launched against the submodule's working dir.
24791
+ *
24792
+ * Sidebar tab + branch / tag sort are also captured into the return
24793
+ * snapshot (#995) so popping back restores the parent's choices
24794
+ * instead of letting the submodule's tab/sort bleed across the
24795
+ * boundary. The values on the *new* frame are left as-is (carried
24796
+ * over from the parent) — the load effect in app.ts re-reads
24797
+ * persistence keyed on the submodule's workdir and dispatches a
24798
+ * restore if the user has a submodule-specific saved preference.
24799
+ *
24800
+ * Other preferences (palette recents, inspector tab, diff view mode)
24801
+ * stay global by design — the user's preference shouldn't reset when
24802
+ * they cross a submodule boundary.
24803
+ *
24804
+ * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
24805
+ * outside the reducer in `app.ts`'s parallel ref structure — this
24806
+ * helper only manages the pure view-model side of the push.
24807
+ */
24808
+ function withPushedRepoFrame(state, payload) {
24809
+ const newFrame = {
24810
+ label: payload.label,
24811
+ workdir: payload.workdir,
24812
+ entryRange: payload.entryRange,
24813
+ parentReturn: {
24814
+ activeView: state.activeView,
24815
+ selectedIndex: state.selectedIndex,
24816
+ selectedFileIndex: state.selectedFileIndex,
24817
+ selectedSubmoduleIndex: state.selectedSubmoduleIndex,
24818
+ filter: state.filter,
24819
+ sidebarTab: state.sidebarTab,
24820
+ userSidebarTab: state.userSidebarTab,
24821
+ branchSort: state.branchSort,
24822
+ tagSort: state.tagSort,
24823
+ },
24824
+ };
24119
24825
  return {
24120
- activeView: initialView,
24121
- viewStack: [initialView],
24122
- rows,
24123
- commits,
24124
- filteredCommits: commits,
24826
+ ...state,
24827
+ repoStack: [...state.repoStack, newFrame],
24828
+ activeView: 'history',
24829
+ viewStack: ['history'],
24125
24830
  selectedIndex: 0,
24126
24831
  selectedFileIndex: 0,
24127
- selectedWorktreeFileIndex: 0,
24128
- selectedWorktreeHunkIndex: 0,
24129
- selectedBranchIndex: 0,
24130
- selectedTagIndex: 0,
24131
- selectedStashIndex: 0,
24132
- selectedWorktreeListIndex: 0,
24133
- selectedConflictFileIndex: 0,
24134
- selectedReflogIndex: 0,
24135
24832
  selectedSubmoduleIndex: 0,
24136
- selectedIssueIndex: 0,
24137
- selectedPullRequestTriageIndex: 0,
24138
- selectedIssueFilter: 'open',
24139
- selectedPullRequestFilter: 'open',
24140
- repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
24141
- branchSort: DEFAULT_BRANCH_SORT_MODE,
24142
- tagSort: DEFAULT_TAG_SORT_MODE,
24143
- paletteFilter: '',
24144
- paletteSelectedIndex: 0,
24145
- paletteRecent: [],
24146
- commitCompose: createCommitComposeState(),
24147
- diffPreviewOffset: 0,
24148
- worktreeDiffOffset: 0,
24149
24833
  filter: '',
24150
24834
  filterMode: false,
24151
- // Default to the full multi-ref graph (`git log --all`) so users
24152
- // see how branches, tags, and stashes weave through the history
24153
- // out of the box. Pre-0.54.x this defaulted to false (current
24154
- // branch only); user feedback consistently asked for the
24155
- // GitKraken-style "see everything" view as the starting state.
24156
- // The `\` toggle still flips back to compact / current-branch
24157
- // mode for users who want the cleaner single-line graph. Tests
24158
- // override via `options.fullGraph` when they need the compact
24159
- // case explicitly.
24160
- fullGraph: options.fullGraph ?? true,
24161
- showHelp: false,
24162
- helpScrollOffset: 0,
24163
- showCommandPalette: false,
24164
- workflowActionId: undefined,
24835
+ pendingCommitFocused: false,
24836
+ pendingKey: undefined,
24165
24837
  pendingConfirmationId: undefined,
24166
24838
  pendingConfirmationPayload: undefined,
24167
24839
  pendingMutationConfirmation: undefined,
24168
- pendingKey: undefined,
24840
+ };
24841
+ }
24842
+ /**
24843
+ * Pop the top repo frame off `state.repoStack` (#931) and restore
24844
+ * the parent's view position from the captured `parentReturn`. A
24845
+ * no-op when the stack is already at its single root frame so this
24846
+ * action is safe to dispatch from generic input handlers (e.g. the
24847
+ * Esc auto-pop wiring that lands in a follow-up PR).
24848
+ *
24849
+ * The defensive `parentReturn` fallback handles the never-supposed-
24850
+ * to-happen case where a non-root frame somehow has no return state
24851
+ * recorded — drop the frame but leave the user's view position
24852
+ * alone rather than crash mid-session.
24853
+ */
24854
+ function withPoppedRepoFrame(state) {
24855
+ if (state.repoStack.length <= 1) {
24856
+ return { ...state, pendingKey: undefined };
24857
+ }
24858
+ const topFrame = state.repoStack[state.repoStack.length - 1];
24859
+ const ret = topFrame.parentReturn;
24860
+ const repoStack = state.repoStack.slice(0, -1);
24861
+ if (!ret) {
24862
+ return { ...state, repoStack, pendingKey: undefined };
24863
+ }
24864
+ return {
24865
+ ...state,
24866
+ repoStack,
24867
+ activeView: ret.activeView,
24868
+ viewStack: [ret.activeView],
24869
+ selectedIndex: ret.selectedIndex,
24870
+ selectedFileIndex: ret.selectedFileIndex,
24871
+ selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
24872
+ filter: ret.filter,
24873
+ filterMode: false,
24874
+ pendingCommitFocused: false,
24875
+ // #995 — restore sidebar tab + sort preferences from the captured
24876
+ // parentReturn. Without this, the submodule's tab / sort choice
24877
+ // bleeds back into the parent after pop: the user picks 'tags' in
24878
+ // a vendored submodule, pops back to the parent, and finds the
24879
+ // parent's previously-selected 'branches' tab quietly replaced.
24880
+ sidebarTab: ret.sidebarTab,
24881
+ userSidebarTab: ret.userSidebarTab,
24882
+ branchSort: ret.branchSort,
24883
+ tagSort: ret.tagSort,
24884
+ pendingKey: undefined,
24885
+ pendingConfirmationId: undefined,
24886
+ pendingConfirmationPayload: undefined,
24887
+ pendingMutationConfirmation: undefined,
24888
+ };
24889
+ }
24890
+ function withReplacedView(state, value) {
24891
+ if (topOfStack(state.viewStack) === value) {
24892
+ return { ...state, pendingKey: undefined };
24893
+ }
24894
+ const viewStack = [...state.viewStack.slice(0, -1), value];
24895
+ return {
24896
+ ...state,
24897
+ activeView: value,
24898
+ viewStack,
24899
+ worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
24900
+ selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
24901
+ diffSource: value === 'diff' ? state.diffSource : undefined,
24902
+ stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
24903
+ compareHead: value === 'diff' ? state.compareHead : undefined,
24904
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24905
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
24906
+ pendingKey: undefined,
24907
+ };
24908
+ }
24909
+ function withFilter(state, filter, promotedSelections) {
24910
+ const filteredCommits = filterCommits(state.commits, filter);
24911
+ // P4.5: rectify promoted-view selections when the filter changes. Prefer
24912
+ // the runtime-supplied snapshot — which preserves the cursor on the same
24913
+ // item when it's still in the filtered list and only snaps to result[0]
24914
+ // when the previously-selected item dropped out. Falls back to the older
24915
+ // "snap to 0" behavior when no snapshot was provided (test paths,
24916
+ // dispatchers without context).
24917
+ const filterChanged = state.filter !== filter;
24918
+ const branchIndex = promotedSelections?.branchIndex ??
24919
+ (filterChanged ? 0 : state.selectedBranchIndex);
24920
+ const tagIndex = promotedSelections?.tagIndex ??
24921
+ (filterChanged ? 0 : state.selectedTagIndex);
24922
+ const stashIndex = promotedSelections?.stashIndex ??
24923
+ (filterChanged ? 0 : state.selectedStashIndex);
24924
+ // Reflog (#781) snaps to 0 on filter change rather than rectifying.
24925
+ // The list is chronological and the user is unlikely to be tracking
24926
+ // a specific entry through filter changes — the simpler reset
24927
+ // matches the "find recovery target by typing" interaction.
24928
+ const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
24929
+ return {
24930
+ ...state,
24931
+ filter,
24932
+ filteredCommits,
24933
+ selectedIndex: clampIndex(state.selectedIndex, filteredCommits.length),
24934
+ selectedFileIndex: 0,
24935
+ selectedBranchIndex: branchIndex,
24936
+ selectedTagIndex: tagIndex,
24937
+ selectedStashIndex: stashIndex,
24938
+ selectedReflogIndex: reflogIndex,
24939
+ diffPreviewOffset: 0,
24940
+ pendingKey: undefined,
24941
+ };
24942
+ }
24943
+ function replaceRows(state, rows) {
24944
+ // Wholesale row replacement after a server-side re-fetch (#776).
24945
+ // Resets the cursor to the top because the new commit set may not
24946
+ // share any hashes with the old one (e.g. switching from `--all` to
24947
+ // `-- some/path` typically dumps the previous selection).
24948
+ const commits = getCommitRows(rows);
24949
+ const filteredCommits = filterCommits(commits, state.filter);
24950
+ return {
24951
+ ...state,
24952
+ rows,
24953
+ commits,
24954
+ filteredCommits,
24955
+ selectedIndex: 0,
24956
+ selectedFileIndex: 0,
24957
+ pendingCommitFocused: false,
24958
+ pendingKey: undefined,
24959
+ // Rows just landed — clear the boot-loading flag so the history
24960
+ // surface drops the "Loading commits…" placeholder. Safe to clear
24961
+ // unconditionally because `replaceRows` only fires after a real
24962
+ // git log returns.
24963
+ bootLoading: false,
24964
+ };
24965
+ }
24966
+ function appendRows(state, rows) {
24967
+ const selected = getSelectedInkCommit(state);
24968
+ const nextRows = [...state.rows, ...rows];
24969
+ const seen = new Set();
24970
+ const commits = getCommitRows(nextRows).filter((commit) => {
24971
+ if (seen.has(commit.hash)) {
24972
+ return false;
24973
+ }
24974
+ seen.add(commit.hash);
24975
+ return true;
24976
+ });
24977
+ const filteredCommits = filterCommits(commits, state.filter);
24978
+ const selectedIndex = selected
24979
+ ? filteredCommits.findIndex((commit) => commit.hash === selected.hash)
24980
+ : state.selectedIndex;
24981
+ return {
24982
+ ...state,
24983
+ rows: nextRows,
24984
+ commits,
24985
+ filteredCommits,
24986
+ selectedIndex: selectedIndex >= 0
24987
+ ? selectedIndex
24988
+ : clampIndex(state.selectedIndex, filteredCommits.length),
24989
+ pendingKey: undefined,
24990
+ };
24991
+ }
24992
+ function nextHunkOffset(currentOffset, hunkOffsets, delta) {
24993
+ if (hunkOffsets.length === 0) {
24994
+ return currentOffset;
24995
+ }
24996
+ if (delta > 0) {
24997
+ const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
24998
+ return nextOffset === undefined ? currentOffset : nextOffset;
24999
+ }
25000
+ const previousOffset = [...hunkOffsets].reverse().find((offset) => offset < currentOffset);
25001
+ return previousOffset === undefined ? currentOffset : previousOffset;
25002
+ }
25003
+ function nextHunkIndex(currentOffset, hunkOffsets, delta) {
25004
+ const offset = nextHunkOffset(currentOffset, hunkOffsets, delta);
25005
+ return Math.max(0, hunkOffsets.indexOf(offset));
25006
+ }
25007
+ function getLogInkSidebarTabs() {
25008
+ return [...SIDEBAR_TABS];
25009
+ }
25010
+ function createLogInkState(rows, options = {}) {
25011
+ const commits = getCommitRows(rows);
25012
+ const initialView = options.activeView || 'history';
25013
+ return {
25014
+ activeView: initialView,
25015
+ viewStack: [initialView],
25016
+ rows,
25017
+ commits,
25018
+ filteredCommits: commits,
25019
+ selectedIndex: 0,
25020
+ selectedFileIndex: 0,
25021
+ selectedWorktreeFileIndex: 0,
25022
+ selectedWorktreeHunkIndex: 0,
25023
+ selectedBranchIndex: 0,
25024
+ selectedTagIndex: 0,
25025
+ selectedStashIndex: 0,
25026
+ selectedWorktreeListIndex: 0,
25027
+ selectedConflictFileIndex: 0,
25028
+ selectedReflogIndex: 0,
25029
+ selectedSubmoduleIndex: 0,
25030
+ selectedIssueIndex: 0,
25031
+ selectedPullRequestTriageIndex: 0,
25032
+ selectedIssueFilter: 'open',
25033
+ selectedPullRequestFilter: 'open',
25034
+ repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
25035
+ branchSort: DEFAULT_BRANCH_SORT_MODE,
25036
+ tagSort: DEFAULT_TAG_SORT_MODE,
25037
+ paletteFilter: '',
25038
+ paletteSelectedIndex: 0,
25039
+ paletteRecent: [],
25040
+ showThemePicker: false,
25041
+ themePickerFilter: '',
25042
+ themePickerIndex: 0,
25043
+ commitCompose: createCommitComposeState(),
25044
+ diffPreviewOffset: 0,
25045
+ worktreeDiffOffset: 0,
25046
+ filter: '',
25047
+ filterMode: false,
25048
+ // Default to the full multi-ref graph (`git log --all`) so users
25049
+ // see how branches, tags, and stashes weave through the history
25050
+ // out of the box. Pre-0.54.x this defaulted to false (current
25051
+ // branch only); user feedback consistently asked for the
25052
+ // GitKraken-style "see everything" view as the starting state.
25053
+ // The `\` toggle still flips back to compact / current-branch
25054
+ // mode for users who want the cleaner single-line graph. Tests
25055
+ // override via `options.fullGraph` when they need the compact
25056
+ // case explicitly.
25057
+ fullGraph: options.fullGraph ?? true,
25058
+ showHelp: false,
25059
+ helpScrollOffset: 0,
25060
+ showCommandPalette: false,
25061
+ workflowActionId: undefined,
25062
+ pendingConfirmationId: undefined,
25063
+ pendingConfirmationPayload: undefined,
25064
+ pendingMutationConfirmation: undefined,
25065
+ pendingKey: undefined,
24169
25066
  focus: 'commits',
24170
25067
  // Default first-time tab is 'branches' — it's the most useful
24171
25068
  // landing surface in the workstation (current branch + recent
@@ -24904,6 +25801,46 @@ function applyLogInkAction(state, action) {
24904
25801
  pendingKey: undefined,
24905
25802
  };
24906
25803
  }
25804
+ case 'toggleThemePicker': {
25805
+ const opening = !state.showThemePicker;
25806
+ return {
25807
+ ...state,
25808
+ showThemePicker: opening,
25809
+ // Only one overlay at a time — close help / palette on open.
25810
+ showHelp: false,
25811
+ showCommandPalette: false,
25812
+ themePickerFilter: '',
25813
+ themePickerIndex: 0,
25814
+ pendingKey: undefined,
25815
+ };
25816
+ }
25817
+ case 'moveThemePicker':
25818
+ return {
25819
+ ...state,
25820
+ themePickerIndex: clampIndex(state.themePickerIndex + action.delta, action.presetCount),
25821
+ pendingKey: undefined,
25822
+ };
25823
+ case 'appendThemePickerFilter':
25824
+ return {
25825
+ ...state,
25826
+ themePickerFilter: `${state.themePickerFilter}${action.value}`,
25827
+ themePickerIndex: 0,
25828
+ pendingKey: undefined,
25829
+ };
25830
+ case 'backspaceThemePickerFilter':
25831
+ return {
25832
+ ...state,
25833
+ themePickerFilter: state.themePickerFilter.slice(0, -1),
25834
+ themePickerIndex: 0,
25835
+ pendingKey: undefined,
25836
+ };
25837
+ case 'clearThemePickerFilter':
25838
+ return {
25839
+ ...state,
25840
+ themePickerFilter: '',
25841
+ themePickerIndex: 0,
25842
+ pendingKey: undefined,
25843
+ };
24907
25844
  case 'setChangelogLoading':
24908
25845
  return {
24909
25846
  ...state,
@@ -25111,6 +26048,71 @@ function applyLogInkAction(state, action) {
25111
26048
  return state;
25112
26049
  }
25113
26050
  }
26051
+ /**
26052
+ * Fuzzy (subsequence) score for a preset id against a lowercase query.
26053
+ * Returns `null` when the query chars don't appear in order; otherwise a
26054
+ * score where contiguous runs, a start-of-string match, and matches right
26055
+ * after a `-` separator are rewarded — so `gl` ranks `gruvbox-light` /
26056
+ * `github-light` above incidental matches, and `tn` finds `tokyo-night`.
26057
+ */
26058
+ function fuzzyScoreThemePreset(preset, query) {
26059
+ const target = preset.toLowerCase();
26060
+ let qi = 0;
26061
+ let score = 0;
26062
+ let lastMatch = -2;
26063
+ for (let i = 0; i < target.length && qi < query.length; i += 1) {
26064
+ if (target[i] === query[qi]) {
26065
+ score += 1;
26066
+ if (i === lastMatch + 1)
26067
+ score += 4; // contiguous run
26068
+ if (i === 0)
26069
+ score += 8; // matches the very start
26070
+ else if (target[i - 1] === '-')
26071
+ score += 4; // start of a word segment
26072
+ lastMatch = i;
26073
+ qi += 1;
26074
+ }
26075
+ }
26076
+ return qi === query.length ? score : null;
26077
+ }
26078
+ /**
26079
+ * Filter the full preset list by a fuzzy (subsequence) query, ranked best
26080
+ * match first (ties broken by catalog order). An empty query returns every
26081
+ * preset in catalog order. Shared by the theme picker overlay renderer, the
26082
+ * input handler (for cursor bounds), and the live-preview selector so all
26083
+ * three agree on the same filtered list.
26084
+ */
26085
+ function filterThemePresets(filter) {
26086
+ const query = filter.trim().toLowerCase();
26087
+ const all = getLogInkThemePresets();
26088
+ if (!query) {
26089
+ return all;
26090
+ }
26091
+ return all
26092
+ .map((preset, index) => ({ preset, index, score: fuzzyScoreThemePreset(preset, query) }))
26093
+ .filter((entry) => entry.score !== null)
26094
+ .sort((a, b) => b.score - a.score || a.index - b.index)
26095
+ .map((entry) => entry.preset);
26096
+ }
26097
+ /**
26098
+ * The preset currently under the theme-picker cursor (clamped to the
26099
+ * filtered list). `undefined` when the filter matches nothing.
26100
+ */
26101
+ function getThemePickerSelection(state) {
26102
+ return getThemePickerSelectionFor(state.themePickerFilter, state.themePickerIndex);
26103
+ }
26104
+ /**
26105
+ * State-model-agnostic variant: the preset under the picker cursor for a
26106
+ * raw `filter` + `index`. Used by the workspace top-level surface, which
26107
+ * keeps its own state shape but shares the picker filtering.
26108
+ */
26109
+ function getThemePickerSelectionFor(filter, index) {
26110
+ const filtered = filterThemePresets(filter);
26111
+ if (filtered.length === 0) {
26112
+ return undefined;
26113
+ }
26114
+ return filtered[clampIndex(index, filtered.length)];
26115
+ }
25114
26116
 
25115
26117
  /**
25116
26118
  * In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
@@ -25572,6 +26574,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
25572
26574
  case 'commandPalette':
25573
26575
  // Re-toggling closes; the dispatcher will close after execute anyway.
25574
26576
  return [];
26577
+ case 'themePicker':
26578
+ // Palette closes on execute (toggleCommandPalette runs first), then
26579
+ // this opens the theme picker.
26580
+ return [action({ type: 'toggleThemePicker' })];
25575
26581
  case 'workflowDeleteBranch':
25576
26582
  case 'workflowDeleteTag':
25577
26583
  case 'workflowDropStash':
@@ -26104,6 +27110,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26104
27110
  // overlay open without dispatching any state change.
26105
27111
  return [];
26106
27112
  }
27113
+ if (state.showThemePicker) {
27114
+ const filtered = filterThemePresets(state.themePickerFilter);
27115
+ if (key.escape) {
27116
+ // Two-stage Esc: clear a non-empty filter first, then close (and
27117
+ // revert the live preview to the previously-active theme).
27118
+ if (state.themePickerFilter.length > 0) {
27119
+ return [action({ type: 'clearThemePickerFilter' })];
27120
+ }
27121
+ return [action({ type: 'toggleThemePicker' })];
27122
+ }
27123
+ if (key.return) {
27124
+ const selected = getThemePickerSelection(state);
27125
+ if (!selected) {
27126
+ return [action({ type: 'toggleThemePicker' })];
27127
+ }
27128
+ return [
27129
+ action({ type: 'toggleThemePicker' }),
27130
+ { type: 'applyThemePreset', preset: selected },
27131
+ ];
27132
+ }
27133
+ if (key.upArrow || (key.ctrl && inputValue === 'p')) {
27134
+ return [action({ type: 'moveThemePicker', delta: -1, presetCount: filtered.length })];
27135
+ }
27136
+ if (key.downArrow || (key.ctrl && inputValue === 'n')) {
27137
+ return [action({ type: 'moveThemePicker', delta: 1, presetCount: filtered.length })];
27138
+ }
27139
+ if (key.backspace || key.delete) {
27140
+ return [action({ type: 'backspaceThemePickerFilter' })];
27141
+ }
27142
+ if (key.ctrl && inputValue === 'u') {
27143
+ return [action({ type: 'clearThemePickerFilter' })];
27144
+ }
27145
+ // All other printable input filters the list (so `j`/`k` type into the
27146
+ // filter rather than navigating — matching the command palette).
27147
+ if (inputValue && !key.ctrl && !key.meta) {
27148
+ return [action({ type: 'appendThemePickerFilter', value: inputValue })];
27149
+ }
27150
+ return [];
27151
+ }
26107
27152
  if (state.showCommandPalette) {
26108
27153
  const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
26109
27154
  if (key.escape) {
@@ -26374,6 +27419,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26374
27419
  action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
26375
27420
  ];
26376
27421
  }
27422
+ // gC — open the theme picker (browse + live-preview + apply a color theme).
27423
+ if (state.pendingKey === 'g' && inputValue === 'C') {
27424
+ return [
27425
+ action({ type: 'setPendingKey', value: undefined }),
27426
+ action({ type: 'toggleThemePicker' }),
27427
+ ];
27428
+ }
26377
27429
  // #784 — bisect view action keys. Scoped to `state.activeView ===
26378
27430
  // 'bisect' && state.focus === 'commits'` so the single-letter keys
26379
27431
  // stay free everywhere else. `g` and `b` collide with the global
@@ -27845,6 +28897,60 @@ function markOnboardingSeen() {
27845
28897
  }
27846
28898
  }
27847
28899
 
28900
+ /**
28901
+ * Persist the user's chosen `coco ui` theme preset to the global XDG
28902
+ * config (`$XDG_CONFIG_HOME/coco/config.json`, default `~/.config/...`),
28903
+ * so a theme picked in the workstation sticks across every repo and
28904
+ * launch. This is the same file `loadXDGConfig` reads.
28905
+ *
28906
+ * Read-modify-write that preserves every other key (we only touch
28907
+ * `logTui.theme.preset`), unlike the whole-object project-config writer.
28908
+ * Best-effort: a read-only HOME or malformed file never throws — the
28909
+ * picker still applies the theme for the session.
28910
+ */
28911
+ const VALID_PRESETS = new Set(getLogInkThemePresets());
28912
+ function getXdgConfigPath() {
28913
+ const home = process.env.XDG_CONFIG_HOME || path$1.join(os$1.homedir(), '.config');
28914
+ return path$1.join(home, 'coco', 'config.json');
28915
+ }
28916
+ function isRecord(value) {
28917
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28918
+ }
28919
+ /**
28920
+ * Write `logTui.theme.preset = <preset>` into the global config, merging
28921
+ * into any existing content. Returns `true` on success, `false` if the
28922
+ * preset is unknown or the write failed (caller treats failure as
28923
+ * "applied for this session only").
28924
+ */
28925
+ function saveThemePreset(preset) {
28926
+ if (!VALID_PRESETS.has(preset)) {
28927
+ return false;
28928
+ }
28929
+ const file = getXdgConfigPath();
28930
+ try {
28931
+ let config = {};
28932
+ try {
28933
+ const parsed = JSON.parse(fs$1.readFileSync(file, 'utf8'));
28934
+ if (isRecord(parsed)) {
28935
+ config = parsed;
28936
+ }
28937
+ }
28938
+ catch {
28939
+ // No existing file (or unreadable/malformed) — start fresh.
28940
+ config = {};
28941
+ }
28942
+ const logTui = isRecord(config.logTui) ? config.logTui : {};
28943
+ const theme = isRecord(logTui.theme) ? logTui.theme : {};
28944
+ config.logTui = { ...logTui, theme: { ...theme, preset } };
28945
+ fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
28946
+ fs$1.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
28947
+ return true;
28948
+ }
28949
+ catch {
28950
+ return false;
28951
+ }
28952
+ }
28953
+
27848
28954
  /**
27849
28955
  * Status-line hints for "what to do next" after a workflow that
27850
28956
  * mutates the worktree (split-apply, etc.). Pure formatting — the
@@ -32180,6 +33286,10 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
32180
33286
  // row's dim and read as quiet chrome.
32181
33287
  h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
32182
33288
  });
33289
+ // Scroll indicators — same "N more above/below" pattern as the
33290
+ // sidebar and help overlay so the user knows the list continues.
33291
+ const branchesHasMoreAbove = startIndex > 0 && localBranches.length > 0;
33292
+ const branchesHasMoreBelow = startIndex + listRows < localBranches.length;
32183
33293
  return h(Box, {
32184
33294
  borderColor: focusBorderColor(theme, focused),
32185
33295
  borderStyle: theme.borderStyle,
@@ -32187,7 +33297,11 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
32187
33297
  flexShrink: 0,
32188
33298
  paddingX: 1,
32189
33299
  width,
32190
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
33300
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(branchesHasMoreAbove
33301
+ ? [h(Text, { key: 'branches-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
33302
+ : []), ...lines, ...(branchesHasMoreBelow
33303
+ ? [h(Text, { key: 'branches-more-below', dimColor: true }, ` ↓ ${localBranches.length - (startIndex + listRows)} more below`)]
33304
+ : []));
32191
33305
  }
32192
33306
 
32193
33307
  /**
@@ -32960,12 +34074,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
32960
34074
  // sees at a glance which file the cursor is inside.
32961
34075
  const isActive = absoluteIndex === activeStartLine;
32962
34076
  const arrow = theme.ascii ? '> ' : '▾ ';
34077
+ const activeHeader = isActive && focused && !theme.noColor;
32963
34078
  return h(Text, {
32964
34079
  key: `stash-diff-line-${absoluteIndex}`,
32965
34080
  bold: true,
32966
- color: theme.noColor ? undefined : theme.colors.accent,
32967
- backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
32968
- inverse: isActive && focused,
34081
+ // Active header sits on the selection bar with a
34082
+ // contrast-guaranteed foreground (matches history/status).
34083
+ // The old `inverse` swap turned the accent into the bar and
34084
+ // left the path in the selection color — low-contrast on
34085
+ // light themes (e.g. accent blue bar + light-gray text).
34086
+ color: activeHeader
34087
+ ? theme.colors.selectionForeground
34088
+ : (theme.noColor ? undefined : theme.colors.accent),
34089
+ backgroundColor: activeHeader ? theme.colors.selection : undefined,
32969
34090
  }, (() => {
32970
34091
  // Smart path truncation for the diff file header: keep
32971
34092
  // the leading arrow glyph and elide middle path
@@ -34043,7 +35164,7 @@ function formatHistoryFetchArgs(args) {
34043
35164
  * Returns the spans flat so the caller can splat them into the row's
34044
35165
  * outer Text alongside other segments without an extra wrapper.
34045
35166
  */
34046
- function renderTypedSubject(h, Text, text, theme, key) {
35167
+ function renderTypedSubject(h, Text, text, theme, key, suppressColor = false) {
34047
35168
  const parsed = parseConventionalCommitPrefix(text);
34048
35169
  if (!parsed) {
34049
35170
  return [h(Text, { key: `${key}-msg` }, text)];
@@ -34051,7 +35172,9 @@ function renderTypedSubject(h, Text, text, theme, key) {
34051
35172
  if (text.length < parsed.prefix.length) {
34052
35173
  return [h(Text, { key: `${key}-msg` }, text)];
34053
35174
  }
34054
- const color = getConventionalCommitColor(parsed, theme);
35175
+ // When the row is selected (inverted), suppress the type color so
35176
+ // text inherits the dark inverted foreground and stays readable.
35177
+ const color = suppressColor ? undefined : getConventionalCommitColor(parsed, theme);
34055
35178
  return [
34056
35179
  h(Text, { key: `${key}-type`, color, bold: parsed.breaking }, parsed.prefix),
34057
35180
  h(Text, { key: `${key}-rest` }, text.slice(parsed.prefix.length)),
@@ -34072,15 +35195,10 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
34072
35195
  const elements = [];
34073
35196
  let totalLen = 0;
34074
35197
  segments.forEach((seg, idx) => {
34075
- const laneColor = getLaneColor(seg.laneId, theme);
35198
+ const laneColor = options.suppressColor ? undefined : (getLaneColor(seg.laneId, theme) ?? muted);
34076
35199
  elements.push(h(Text, {
34077
35200
  key: `${keyPrefix}-${idx}`,
34078
- color: laneColor ?? muted,
34079
- // Ink does not cascade dimColor from a parent Text to children,
34080
- // so the caller's "this whole row should fade" intent has to
34081
- // travel here as an explicit flag (#831). Used for graph-only
34082
- // lane-closure rows, where the lane colors otherwise compete
34083
- // for attention with the commits they connect.
35201
+ color: laneColor,
34084
35202
  dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
34085
35203
  }, seg.text));
34086
35204
  totalLen += seg.text.length;
@@ -34131,18 +35249,26 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
34131
35249
  const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
34132
35250
  const message = truncateCells(commit.message, messageRoom);
34133
35251
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
34134
- const accent = theme.noColor ? undefined : theme.colors.accent;
34135
- const muted = theme.noColor ? undefined : theme.colors.muted;
35252
+ // Don't use inverse it makes child colors unreadable. Instead, set a
35253
+ // background on the row AND an explicit, contrast-guaranteed foreground
35254
+ // (`selectionForeground`, derived from the selection bg) on the outer
35255
+ // span. Suppressing each child's own color to `undefined` then lets it
35256
+ // inherit that readable foreground — so the whole selected row stays
35257
+ // legible regardless of the user's terminal default foreground, which
35258
+ // is what the old "rely on the default fg" approach got wrong.
35259
+ const selectedFg = selected && !theme.noColor ? theme.colors.selectionForeground : undefined;
35260
+ const accent = selected ? undefined : (theme.noColor ? undefined : theme.colors.accent);
35261
+ const muted = selected ? undefined : (theme.noColor ? undefined : theme.colors.muted);
34136
35262
  // Lane-colored graph spans when full graph mode + non-ASCII rendering
34137
35263
  // is in play; otherwise fall back to the legacy single-muted span so
34138
35264
  // compact mode and legacy terminals stay visually unchanged.
34139
35265
  const graphChildren = laneSegments && !theme.ascii
34140
- ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
34141
- : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
35266
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`, { suppressColor: selected })
35267
+ : [h(Text, { color: muted, dimColor: !selected && theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
34142
35268
  return h(Text, {
34143
35269
  key: `${commit.hash}-${index}`,
34144
35270
  backgroundColor: selectedBg,
34145
- inverse: selected,
35271
+ color: selectedFg,
34146
35272
  }, ...graphChildren, ' ',
34147
35273
  // "Just landed" marker — a single thick vertical bar in the
34148
35274
  // accent color before the short hash. Fades when the runtime
@@ -34164,11 +35290,11 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
34164
35290
  // Date column drops out entirely at `tight` density — no spacer
34165
35291
  // either, so the message column slides left into the freed cells.
34166
35292
  dateText
34167
- ? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: true }, dateText, ' ')
35293
+ ? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: !selected }, dateText, ' ')
34168
35294
  : null,
34169
35295
  // Branch chip prefix (full-graph mode only) lands right before the
34170
35296
  // message so the eye reads "branch · subject" as a unit.
34171
- chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
35297
+ chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`, selected), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
34172
35298
  }
34173
35299
  /**
34174
35300
  * Stacked variant used at `rowMode='stacked'` (rail tier). Each
@@ -34183,9 +35309,13 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
34183
35309
  */
34184
35310
  function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false, remoteNames) {
34185
35311
  const totalWidth = Math.max(20, panelWidth - 4);
34186
- const accent = theme.noColor ? undefined : theme.colors.accent;
34187
- const muted = theme.noColor ? undefined : theme.colors.muted;
35312
+ // Suppress child colors on selected rows so each span inherits the
35313
+ // contrast-guaranteed `selectionForeground` set on the line-1 span,
35314
+ // keeping the selected row readable against the selection bg.
35315
+ const accent = selected ? undefined : (theme.noColor ? undefined : theme.colors.accent);
35316
+ const muted = selected ? undefined : (theme.noColor ? undefined : theme.colors.muted);
34188
35317
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
35318
+ const selectedFg = selected && !theme.noColor ? theme.colors.selectionForeground : undefined;
34189
35319
  // Line 1 — subject row. Mostly mirrors the single-line layout but
34190
35320
  // skips the date and refs so the message has the whole tail to
34191
35321
  // itself. Branch chip rides between the hash and the subject the
@@ -34197,15 +35327,15 @@ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth,
34197
35327
  const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
34198
35328
  const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
34199
35329
  const graphChildren = laneSegments && !theme.ascii
34200
- ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`)
34201
- : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
35330
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`, { suppressColor: selected })
35331
+ : [h(Text, { color: muted, dimColor: !selected && theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
34202
35332
  const lineOne = h(Text, {
34203
35333
  key: `${commit.hash}-${index}-l1`,
34204
35334
  backgroundColor: selectedBg,
34205
- inverse: selected,
35335
+ color: selectedFg,
34206
35336
  }, ...graphChildren, ' ', isRecent
34207
35337
  ? h(Text, { color: accent, bold: true }, theme.ascii ? '* ' : '▎ ')
34208
- : null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`));
35338
+ : null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`, selected));
34209
35339
  // Line 2 — metadata row, padded to align with the start of the
34210
35340
  // shortHash on line 1 so the eye still groups them as one commit.
34211
35341
  // Selection background does not extend here so we don't get a thick
@@ -34258,8 +35388,11 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
34258
35388
  return h(Text, {
34259
35389
  key: 'pending-commit-row',
34260
35390
  bold: true,
34261
- color: theme.noColor ? undefined : theme.colors.accent,
34262
- inverse: selected,
35391
+ // On selection, swap to the contrast-guaranteed foreground so the
35392
+ // accent label doesn't wash out against the selection bar.
35393
+ color: selected && !theme.noColor
35394
+ ? theme.colors.selectionForeground
35395
+ : (theme.noColor ? undefined : theme.colors.accent),
34263
35396
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
34264
35397
  }, truncateCells(label, 140));
34265
35398
  }
@@ -34673,6 +35806,10 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
34673
35806
  dimColor: !isSelected,
34674
35807
  }, truncateCells(line, width - 4));
34675
35808
  });
35809
+ // Scroll indicators for the palette list — same pattern as the
35810
+ // sidebar and help overlay so the user knows there's more content.
35811
+ const paletteHasMoreAbove = startIndex > 0 && filtered.length > 0;
35812
+ const paletteHasMoreBelow = startIndex + listRows < filtered.length;
34676
35813
  return h(Box, {
34677
35814
  borderColor: focusBorderColor(theme, focused),
34678
35815
  borderStyle: theme.borderStyle,
@@ -34681,7 +35818,66 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
34681
35818
  paddingX: 1,
34682
35819
  }, 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
34683
35820
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
34684
- : []), ...itemLines);
35821
+ : []), ...(paletteHasMoreAbove
35822
+ ? [h(Text, { key: 'palette-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
35823
+ : []), ...itemLines, ...(paletteHasMoreBelow
35824
+ ? [h(Text, { key: 'palette-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
35825
+ : []));
35826
+ }
35827
+ /**
35828
+ * Theme picker overlay (`gC`). Renders like the command palette so the
35829
+ * rest of the surface live-previews the cursored theme underneath. Type to
35830
+ * filter, ↑/↓ to move, Enter applies (and persists), Esc cancels. Takes the
35831
+ * raw `filter` + `index` rather than a `LogInkState` so it's reusable by
35832
+ * the workspace top-level surface, which has its own state model.
35833
+ */
35834
+ function renderThemePickerOverlay(h, components, filter, index, width, theme, focused) {
35835
+ const { Box, Text } = components;
35836
+ const filtered = filterThemePresets(filter);
35837
+ const selectedIndex = filtered.length === 0
35838
+ ? 0
35839
+ : Math.max(0, Math.min(index, filtered.length - 1));
35840
+ const listRows = 14;
35841
+ const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
35842
+ const visible = filtered.slice(startIndex, startIndex + listRows);
35843
+ const inputLine = `> ${filter}_`;
35844
+ const matchSummary = filtered.length === 0
35845
+ ? 'no matches'
35846
+ : `${filtered.length} ${filtered.length === 1 ? 'theme' : 'themes'}`;
35847
+ const hint = '↑/↓ select · type to filter · enter apply · esc close';
35848
+ const itemLines = filtered.length === 0
35849
+ ? [h(Text, { key: 'theme-empty', dimColor: true }, 'No themes match the current filter.')]
35850
+ : visible.map((preset, offset) => {
35851
+ const index = startIndex + offset;
35852
+ const isSelected = index === selectedIndex;
35853
+ const cursor = isSelected ? '>' : ' ';
35854
+ // Accent swatch per theme (no swatch for the monochrome baseline or
35855
+ // when color is off). `default` is the only ANSI-named accent.
35856
+ const accent = preset === 'monochrome'
35857
+ ? undefined
35858
+ : THEME_PRESET_COLORS[preset]?.accent;
35859
+ const swatch = accent && !theme.noColor
35860
+ ? h(Text, { key: `theme-swatch-${preset}`, color: accent }, '● ')
35861
+ : h(Text, { key: `theme-swatch-${preset}`, dimColor: true }, '· ');
35862
+ return h(Text, {
35863
+ key: `theme-${preset}`,
35864
+ bold: isSelected,
35865
+ dimColor: !isSelected,
35866
+ }, `${cursor} `, swatch, truncateCells(preset, width - 8));
35867
+ });
35868
+ const hasMoreAbove = startIndex > 0 && filtered.length > 0;
35869
+ const hasMoreBelow = startIndex + listRows < filtered.length;
35870
+ return h(Box, {
35871
+ borderColor: focusBorderColor(theme, focused),
35872
+ borderStyle: theme.borderStyle,
35873
+ flexDirection: 'column',
35874
+ width,
35875
+ paddingX: 1,
35876
+ }, 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
35877
+ ? [h(Text, { key: 'theme-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
35878
+ : []), ...itemLines, ...(hasMoreBelow
35879
+ ? [h(Text, { key: 'theme-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
35880
+ : []));
34685
35881
  }
34686
35882
  /**
34687
35883
  * Split-plan overlay (#907) — renders the proposed commit groups for
@@ -35537,6 +36733,8 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
35537
36733
  dimColor: !isSelected,
35538
36734
  }, truncateCells(`${cursor} ${stash.ref.padEnd(12)} ${stash.message}`, 140));
35539
36735
  });
36736
+ const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
36737
+ const stashHasMoreBelow = startIndex + listRows < stashes.length;
35540
36738
  return h(Box, {
35541
36739
  borderColor: focusBorderColor(theme, focused),
35542
36740
  borderStyle: theme.borderStyle,
@@ -35544,7 +36742,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
35544
36742
  flexShrink: 0,
35545
36743
  paddingX: 1,
35546
36744
  width,
35547
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
36745
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(stashHasMoreAbove
36746
+ ? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
36747
+ : []), ...lines, ...(stashHasMoreBelow
36748
+ ? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
36749
+ : []));
35548
36750
  }
35549
36751
 
35550
36752
  /**
@@ -35640,7 +36842,7 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35640
36842
  bold: true,
35641
36843
  dimColor: !headerSelected && rowIndex > cursorRowIndex,
35642
36844
  backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
35643
- inverse: headerSelected,
36845
+ color: headerSelected && !theme.noColor ? theme.colors.selectionForeground : undefined,
35644
36846
  }, truncateCells(text, 140));
35645
36847
  }
35646
36848
  const isSelected = !headerFocused && row.flatIndex === selectedIndex;
@@ -35660,8 +36862,11 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35660
36862
  key: `status-file-${row.flatIndex}-${rowIndex}`,
35661
36863
  dimColor: !isSelected && rowIndex > cursorRowIndex,
35662
36864
  backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
35663
- inverse: isSelected && focused,
35664
- }, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
36865
+ color: isSelected && focused && !theme.noColor ? theme.colors.selectionForeground : undefined,
36866
+ }, ` ${cursorPart}`,
36867
+ // Suppress the dot's own color on selected rows so it inherits the
36868
+ // contrast-guaranteed selection foreground set on the row span.
36869
+ ...(useDot ? [h(Text, { color: (isSelected && focused) ? undefined : dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
35665
36870
  });
35666
36871
  // When the mask narrows the list to nothing but the underlying repo
35667
36872
  // is non-clean, surface why the panel looks empty so the user can
@@ -35676,6 +36881,10 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35676
36881
  : cleanHint
35677
36882
  ? [cleanHint]
35678
36883
  : ['Worktree clean'];
36884
+ // Scroll indicators for the status file list — same pattern as
36885
+ // branches and the sidebar so the user knows there's more content.
36886
+ const statusHasMoreAbove = windowStart > 0 && surfaceRows.length > 0;
36887
+ const statusHasMoreBelow = windowStart + listRows < surfaceRows.length;
35679
36888
  return h(Box, {
35680
36889
  borderColor: focusBorderColor(theme, focused),
35681
36890
  borderStyle: theme.borderStyle,
@@ -35691,7 +36900,11 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
35691
36900
  // never touch the filter.
35692
36901
  ...(isStatusFilterMaskActive(state.statusFilterMask)
35693
36902
  ? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
35694
- : []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
36903
+ : []), ...(statusHasMoreAbove
36904
+ ? [h(Text, { key: 'status-more-above', dimColor: true }, ` ↑ ${windowStart} more above`)]
36905
+ : []), ...renderedRows, ...(statusHasMoreBelow
36906
+ ? [h(Text, { key: 'status-more-below', dimColor: true }, ` ↓ ${surfaceRows.length - (windowStart + listRows)} more below`)]
36907
+ : []), ...fallbackLines.map((line, index) => h(Text, {
35695
36908
  key: `status-surface-fallback-${index}`,
35696
36909
  dimColor: index > 0,
35697
36910
  }, truncateCells(line, 140))));
@@ -35921,6 +37134,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
35921
37134
  dimColor: !isSelected,
35922
37135
  }, before, formatHyperlink(namePadded, url), after);
35923
37136
  });
37137
+ const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
37138
+ const tagsHasMoreBelow = startIndex + listRows < tags.length;
35924
37139
  return h(Box, {
35925
37140
  borderColor: focusBorderColor(theme, focused),
35926
37141
  borderStyle: theme.borderStyle,
@@ -35928,7 +37143,11 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
35928
37143
  flexShrink: 0,
35929
37144
  paddingX: 1,
35930
37145
  width,
35931
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
37146
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(tagsHasMoreAbove
37147
+ ? [h(Text, { key: 'tags-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
37148
+ : []), ...lines, ...(tagsHasMoreBelow
37149
+ ? [h(Text, { key: 'tags-more-below', dimColor: true }, ` ↓ ${tags.length - (startIndex + listRows)} more below`)]
37150
+ : []));
35932
37151
  }
35933
37152
 
35934
37153
  /**
@@ -36447,12 +37666,17 @@ function renderInspectorActionsSection(h, Text, context, width, theme, options =
36447
37666
  h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
36448
37667
  ...actions.map((action, index) => {
36449
37668
  const isSelected = cursorActive && index === cursorIndex;
37669
+ // On the selected row, swap every span to the contrast-guaranteed
37670
+ // selection foreground so the key glyph / destructive marker don't
37671
+ // wash out against the selection bar; the row is already highlighted,
37672
+ // and the label text still conveys which actions are destructive.
37673
+ const selectedFg = isSelected && !theme.noColor ? theme.colors.selectionForeground : undefined;
36450
37674
  const keyCell = action.key.padEnd(KEY_COLUMN);
36451
37675
  const label = truncateCells(action.label, labelBudget);
36452
37676
  const children = [
36453
37677
  h(Text, {
36454
37678
  key: `actions-${index}-key`,
36455
- color: action.destructive ? theme.colors.danger : theme.colors.accent,
37679
+ color: selectedFg ?? (action.destructive ? theme.colors.danger : theme.colors.accent),
36456
37680
  }, keyCell),
36457
37681
  GAP,
36458
37682
  label,
@@ -36460,14 +37684,14 @@ function renderInspectorActionsSection(h, Text, context, width, theme, options =
36460
37684
  if (action.destructive) {
36461
37685
  children.push(h(Text, {
36462
37686
  key: `actions-${index}-mark`,
36463
- color: theme.colors.danger,
37687
+ color: selectedFg ?? theme.colors.danger,
36464
37688
  dimColor: false,
36465
37689
  }, DESTRUCTIVE_SUFFIX));
36466
37690
  }
36467
37691
  return h(Text, {
36468
37692
  key: `actions-${index}`,
36469
37693
  backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
36470
- inverse: isSelected,
37694
+ color: selectedFg,
36471
37695
  }, ...children);
36472
37696
  }),
36473
37697
  ];
@@ -36544,7 +37768,6 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
36544
37768
  return h(Text, {
36545
37769
  key: `commit-file-${index}`,
36546
37770
  color: statusCodeColor(file.status, theme),
36547
- inverse: isSelected && focused && !theme.noColor,
36548
37771
  bold: isSelected,
36549
37772
  }, label);
36550
37773
  });
@@ -37092,6 +38315,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
37092
38315
  if (state.showCommandPalette) {
37093
38316
  return renderCommandPalette(h, components, state, width, theme, focused);
37094
38317
  }
38318
+ if (state.showThemePicker) {
38319
+ return renderThemePickerOverlay(h, components, state.themePickerFilter, state.themePickerIndex, width, theme, focused);
38320
+ }
37095
38321
  if (state.inputPrompt) {
37096
38322
  return renderInputPromptPanel(h, components, state, width, theme, focused);
37097
38323
  }
@@ -37386,9 +38612,21 @@ function enrichFilterActionWithRectification(action, state, context) {
37386
38612
  }
37387
38613
  }
37388
38614
  function LogInkApp(deps) {
37389
- const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
38615
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme: baseTheme, themeConfig } = deps;
37390
38616
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
37391
38617
  const h = React.createElement;
38618
+ // Theme picker (gC) — live preview + apply. `themePreviewPreset` follows
38619
+ // the picker cursor while the overlay is open; `themeSessionPreset` is the
38620
+ // applied choice that survives close. The effective theme is rebuilt from
38621
+ // the original `themeConfig` so ascii/border/noColor + truecolor-downgrade
38622
+ // semantics are preserved; when neither override is set we use the static
38623
+ // `baseTheme` unchanged (so behavior is identical until the picker is used).
38624
+ const [themePreviewPreset, setThemePreviewPreset] = React.useState(undefined);
38625
+ const [themeSessionPreset, setThemeSessionPreset] = React.useState(undefined);
38626
+ const effectiveThemePreset = themePreviewPreset ?? themeSessionPreset;
38627
+ const theme = React.useMemo(() => effectiveThemePreset
38628
+ ? createLogInkTheme({ ...themeConfig, preset: effectiveThemePreset })
38629
+ : baseTheme, [effectiveThemePreset, themeConfig, baseTheme]);
37392
38630
  const { exit } = useApp();
37393
38631
  const windowSize = useWindowSize();
37394
38632
  // Bumping this on SIGCONT forces the existing tree to repaint so users
@@ -37417,6 +38655,17 @@ function LogInkApp(deps) {
37417
38655
  // immediately while the chrome still flags the refresh.
37418
38656
  bootLoading: Boolean(loadRows),
37419
38657
  }));
38658
+ // Theme picker live preview: keep `themePreviewPreset` in sync with the
38659
+ // preset under the picker cursor while the overlay is open; clear it when
38660
+ // the overlay closes so the theme reverts to the applied session preset
38661
+ // (or the original config theme). The derived-theme `useMemo` above does
38662
+ // the actual re-render from this state.
38663
+ const themePickerSelection = state.showThemePicker
38664
+ ? getThemePickerSelection(state)
38665
+ : undefined;
38666
+ React.useEffect(() => {
38667
+ setThemePreviewPreset(state.showThemePicker ? themePickerSelection : undefined);
38668
+ }, [state.showThemePicker, themePickerSelection]);
37420
38669
  // Nested-repo runtime stack (#931). Each frame holds the live
37421
38670
  // `SimpleGit`, the loaded `LogInkContext`, and the per-key load
37422
38671
  // status the chrome reads. The active (top-of-stack) entry drives
@@ -40839,946 +42088,423 @@ function LogInkApp(deps) {
40839
42088
  // (failure), surfacing the right message.
40840
42089
  }
40841
42090
  else {
40842
- dispatch({
40843
- type: 'setStatus',
40844
- value: `${target.label} target commit returned no rows — orphan ref?`,
40845
- kind: 'warning',
40846
- });
40847
- }
40848
- }
40849
- catch (error) {
40850
- if (mountedRef.current) {
40851
- dispatch({
40852
- type: 'setStatus',
40853
- value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
40854
- kind: 'error',
40855
- });
40856
- }
40857
- }
40858
- }, [dispatch, git]);
40859
- React.useEffect(() => {
40860
- loadCommitContextRef.current = loadCommitContext;
40861
- }, [loadCommitContext]);
40862
- // Server-side history filter (#776). When the user submits `path:foo`
40863
- // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
40864
- // this effect picks up the change, re-runs `getLogRows` with merged
40865
- // args, and replaces the rows. Clearing the fetch args (Ctrl+U inside
40866
- // filter mode) re-fetches with the original logArgv so the user gets
40867
- // the live full log back, not a stale snapshot of the initial rows.
40868
- const historyFetchEffectInitialized = React.useRef(false);
40869
- const historyFetchRequestRef = React.useRef(0);
40870
- React.useEffect(() => {
40871
- if (!logArgv)
40872
- return;
40873
- // Skip the first run — initial rows came in via deps.rows; we only
40874
- // want to fetch in response to *changes* to historyFetchArgs.
40875
- if (!historyFetchEffectInitialized.current) {
40876
- historyFetchEffectInitialized.current = true;
40877
- return;
40878
- }
40879
- const requestId = historyFetchRequestRef.current + 1;
40880
- historyFetchRequestRef.current = requestId;
40881
- const fetchArgs = state.historyFetchArgs;
40882
- const merged = {
40883
- ...logArgv,
40884
- ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
40885
- ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
40886
- };
40887
- const description = fetchArgs?.author
40888
- ? `author:${fetchArgs.author}`
40889
- : fetchArgs?.path
40890
- ? `path:${fetchArgs.path}`
40891
- : undefined;
40892
- dispatch({
40893
- type: 'setStatus',
40894
- value: description ? `Refetching with ${description}` : 'Restoring full log',
40895
- });
40896
- void (async () => {
40897
- const stashHashes = await getStashCommitHashes(git).catch(() => []);
40898
- const nextRows = await safe(getLogRows(git, merged, {
40899
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40900
- extraRefs: stashHashes,
40901
- }));
40902
- if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
40903
- return;
40904
- }
40905
- if (!nextRows) {
40906
- dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
40907
- return;
40908
- }
40909
- dispatch({ type: 'replaceRows', rows: nextRows });
40910
- const matched = getCommitRows(nextRows).length;
40911
- setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40912
- dispatch({
40913
- type: 'setStatus',
40914
- value: description
40915
- ? `Showing ${matched} commits matching ${description}`
40916
- : 'Showing full log',
40917
- kind: 'success',
40918
- });
40919
- })();
40920
- }, [dispatch, git, logArgv, state.historyFetchArgs]);
40921
- // Graph mode toggle (`g` key, #791 follow-up). The header label flips
40922
- // between "compact graph" and "full graph", but unless we re-fetch with
40923
- // the right `view`, the underlying rows still come from the user's
40924
- // initial argv (default `--first-parent --no-merges`) and the renderer
40925
- // has no topology to draw — defeating the per-lane / junction work.
40926
- // Mirrors the historyFetchArgs effect: skip first run, request-id ref
40927
- // for stale-completion guard, swap rows in place via replaceRows.
40928
- const toggleGraphEffectInitialized = React.useRef(false);
40929
- const toggleGraphRequestRef = React.useRef(0);
40930
- React.useEffect(() => {
40931
- if (!logArgv)
40932
- return;
40933
- if (!toggleGraphEffectInitialized.current) {
40934
- toggleGraphEffectInitialized.current = true;
40935
- return;
40936
- }
40937
- const requestId = toggleGraphRequestRef.current + 1;
40938
- toggleGraphRequestRef.current = requestId;
40939
- const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
40940
- dispatch({
40941
- type: 'setStatus',
40942
- value: state.fullGraph
40943
- ? 'Loading full topology…'
40944
- : 'Loading compact history…',
40945
- });
40946
- void (async () => {
40947
- // Include stash commits as graph roots so the toggle's re-fetch
40948
- // sees the same rich graph the boot loader assembles. Without
40949
- // this, flipping `\` into full mode and back loses the stash
40950
- // anchors that loadRowsWithStashes seeded on boot.
40951
- const stashHashes = await getStashCommitHashes(git).catch(() => []);
40952
- const nextRows = await safe(getLogRows(git, merged, {
40953
- limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
40954
- extraRefs: stashHashes,
40955
- }));
40956
- if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
40957
- return;
40958
- }
40959
- if (!nextRows) {
40960
- dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
40961
- return;
40962
- }
40963
- dispatch({ type: 'replaceRows', rows: nextRows });
40964
- const matched = getCommitRows(nextRows).length;
40965
- setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
40966
- dispatch({
40967
- type: 'setStatus',
40968
- value: state.fullGraph
40969
- ? `Showing ${matched} commits across all branches`
40970
- : `Showing ${matched} commits (compact)`,
40971
- kind: 'success',
40972
- });
40973
- })();
40974
- }, [dispatch, git, logArgv, state.fullGraph]);
40975
- const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
40976
- .map((line, index) => (line.startsWith('@@') ? index : -1))
40977
- .filter((index) => index >= 0)), [filePreview]);
40978
- const worktreeDirty = Boolean(context.worktree &&
40979
- (context.worktree.stagedCount + context.worktree.unstagedCount + context.worktree.untrackedCount) > 0);
40980
- useInput((inputValue, key) => {
40981
- // First-launch onboarding (P1.3): any keystroke dismisses the overlay
40982
- // and writes the seen-marker. Swallow the keystroke so the same key
40983
- // doesn't also trigger normal input dispatch.
40984
- if (showOnboarding) {
40985
- setShowOnboarding(false);
40986
- markOnboardingSeen();
40987
- return;
40988
- }
40989
- // P4.5: navigation in branches/tags/stash uses the FILTERED list
40990
- // length when a filter is active so j/k stay live instead of getting
40991
- // stuck against a full-list count that no longer matches what's on
40992
- // screen. The filtered lists are memoized at LogInkApp scope (#808
40993
- // perf pass) — reading them here is O(1) instead of O(branches +
40994
- // tags + stashes + worktrees) per keystroke.
40995
- const branchVisibleCount = filteredBranchList.length;
40996
- const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
40997
- const tagVisibleCount = filteredTagList.length;
40998
- const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
40999
- const stashVisibleCount = filteredStashList.length;
41000
- const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
41001
- const reflogVisibleCount = filteredReflogList.length;
41002
- const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
41003
- const submoduleVisibleCount = filteredSubmoduleList.length;
41004
- filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
41005
- const issueVisibleCount = filteredIssueList.length;
41006
- const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
41007
- const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
41008
- const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
41009
- const worktreeVisibleCount = filteredWorktreeList.length;
41010
- // When the diff view is showing a stash patch, swap the previewLineCount
41011
- // to the stash diff length so the existing pageDetailPreview path
41012
- // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
41013
- const diffPreviewLineCount = state.diffSource === 'stash'
41014
- ? stashDiffLines?.length
41015
- : filePreview?.hunks.length;
41016
- // Per-file segmentation for stash diffs reads the LogInkApp-scoped
41017
- // memo so navigation keys + the input-context derivation share a
41018
- // single parse pass per stash patch instead of re-walking the
41019
- // entire patch text on every keystroke.
41020
- const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
41021
- const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
41022
- const stashDiffSelectedPath = state.diffSource === 'stash'
41023
- ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
41024
- : undefined;
41025
- getLogInkInputEvents(state, inputValue, key, {
41026
- detailFileCount: detail?.files.length,
41027
- previewLineCount: diffPreviewLineCount,
41028
- worktreeDiffLineCount: worktreeDiff?.lines.length,
41029
- worktreeFileCount: visibleWorktreeFilesGrouped.length,
41030
- worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
41031
- commitDiffHunkOffsets,
41032
- branchCount: branchVisibleCount,
41033
- branchSelectedShortName,
41034
- tagCount: tagVisibleCount,
41035
- tagSelectedName,
41036
- stashCount: stashVisibleCount,
41037
- reflogCount: reflogVisibleCount,
41038
- reflogSelectedHash,
41039
- submoduleCount: submoduleVisibleCount,
41040
- issueCount: issueVisibleCount,
41041
- issueSelectedUrl,
41042
- pullRequestTriageCount: pullRequestTriageVisibleCount,
41043
- pullRequestTriageSelectedUrl,
41044
- stashSelectedRef,
41045
- stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
41046
- stashDiffSelectedPath,
41047
- worktreeListCount: worktreeVisibleCount,
41048
- worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
41049
- statusGroups: visibleWorktreeGroups.map((group) => ({
41050
- state: group.state,
41051
- count: group.files.length,
41052
- startIndex: group.startIndex,
41053
- })),
41054
- inspectorActionCount: getInspectorActionsForState(state).length,
41055
- commitDiffSelectedPath: state.diffSource === 'commit'
41056
- ? selectedDetailFile?.path
41057
- : undefined,
41058
- commitDiffSelectedSha: state.diffSource === 'commit'
41059
- ? selected?.hash
41060
- : undefined,
41061
- // #931 PR 3b — Submodule drill-in target for the cursored file
41062
- // in a commit diff. Resolved per-render so the Enter handler in
41063
- // `inkInput.ts` doesn't have to re-walk the submodule overview;
41064
- // undefined whenever the cursored file isn't a registered
41065
- // submodule (or the overview / repo root haven't loaded yet).
41066
- commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
41067
- ? resolveCommitDiffDrillInTarget({
41068
- selectedFile: {
41069
- path: selectedDetailFile.path,
41070
- submoduleChange: filePreview?.path === selectedDetailFile.path
41071
- ? filePreview.submoduleChange
41072
- : undefined,
41073
- },
41074
- submodules: context.submodules,
41075
- activeRepoRoot,
41076
- })
41077
- : undefined,
41078
- // #931 PR 4 / #932 — Submodule drill-in target for the cursored
41079
- // row in the dedicated submodules view. Resolved per-render so
41080
- // the Enter handler in `inkInput.ts` doesn't have to re-walk the
41081
- // submodule overview. Gated on `activeView === 'submodules'` so
41082
- // a stale resolution from a different view can't accidentally
41083
- // fire — the runtime only ever populates it when the user is
41084
- // actually on the view.
41085
- submoduleViewDrillIn: state.activeView === 'submodules'
41086
- ? resolveSubmoduleViewDrillInTarget({
41087
- selectedIndex: state.selectedSubmoduleIndex,
41088
- submodules: context.submodules,
41089
- activeRepoRoot,
41090
- })
41091
- : undefined,
41092
- worktreeDirty,
41093
- conflictFileCount: context.operation?.conflictedFiles.length,
41094
- conflictSelectedPath: (() => {
41095
- const files = context.operation?.conflictedFiles;
41096
- if (!files || files.length === 0)
41097
- return undefined;
41098
- const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
41099
- return files[clamped]?.path;
41100
- })(),
41101
- // H / gH need the actual diff text (not just hunk offsets) to
41102
- // slice the cursored hunk into a `git apply` patch. Stash uses
41103
- // the full `git stash show -p` output; commit-diff uses the
41104
- // per-file `filePreview.hunks` array. Either way, extractDiffHunk
41105
- // walks `@@` headers and synthesizes a fresh diff --git / --- /
41106
- // +++ header set using the path the caller already resolved.
41107
- diffLinesForHunkApply: state.diffSource === 'stash'
41108
- ? stashDiffLines
41109
- : state.diffSource === 'commit'
41110
- ? filePreview?.hunks
41111
- : undefined,
41112
- // Line count of the changelog text, used by the changelog view's
41113
- // j/k/PgUp/PgDn scroll bindings to clamp `pageChangelog` deltas.
41114
- // Computed from view state rather than threaded through context
41115
- // because the surface owns its own content — no external loader.
41116
- changelogLineCount: state.changelogView.text?.split('\n').length,
41117
- // Approximate line count for the split-plan overlay. Each group
41118
- // renders as a header + (body if any) + files block + (rationale
41119
- // if any) + blank separator. Used by j/k/PgUp/PgDn to clamp the
41120
- // scroll offset. The exact render math lives in the overlay
41121
- // module — this is a close-enough heuristic for clamping.
41122
- // #879 item 3 — short sha of the bisect terminator (if any).
41123
- // Gates `y`/`Y` yank on the completion panel and lets the
41124
- // runtime resolve the value without re-parsing the log.
41125
- bisectCompletionSha: context.bisect?.active
41126
- ? getBisectCompletion(context.bisect.log)?.sha
41127
- : undefined,
41128
- // #879 item 4 — disambiguates the bisect view's `s` keystroke
41129
- // (skip current candidate vs. start the wizard).
41130
- bisectActive: Boolean(context.bisect?.active),
41131
- splitPlanLineCount: state.splitPlan?.plan
41132
- ? state.splitPlan.plan.groups.reduce((sum, group) => {
41133
- let lines = 2; // title + separator
41134
- if (group.body)
41135
- lines += group.body.split('\n').length + 1;
41136
- if (group.rationale)
41137
- lines += 2;
41138
- lines += (group.files?.length || 0) + 1;
41139
- if ((group.hunks?.length || 0) > 0)
41140
- lines += group.hunks.length + 1;
41141
- return sum + lines;
41142
- }, 0)
41143
- : undefined,
41144
- }).forEach((event) => {
41145
- if (event.type === 'exit') {
41146
- exit();
41147
- }
41148
- else if (event.type === 'refreshContext') {
41149
- void refreshContext();
41150
- }
41151
- else if (event.type === 'toggleSelectedFileStage') {
41152
- void toggleSelectedFileStage();
41153
- }
41154
- else if (event.type === 'toggleSelectedHunkStage') {
41155
- void toggleSelectedHunkStage();
41156
- }
41157
- else if (event.type === 'revertSelectedFile') {
41158
- void revertSelectedFile();
41159
- }
41160
- else if (event.type === 'revertSelectedHunk') {
41161
- void revertSelectedHunk();
41162
- }
41163
- else if (event.type === 'createManualCommit') {
41164
- void createCommitFromCompose();
41165
- }
41166
- else if (event.type === 'runAiCommitDraft') {
41167
- void runAiCommitDraft();
41168
- }
41169
- else if (event.type === 'cancelAiCommitDraft') {
41170
- cancelAiCommitDraft();
41171
- }
41172
- else if (event.type === 'startCreatePullRequest') {
41173
- void startCreatePullRequest();
41174
- }
41175
- else if (event.type === 'cancelPullRequestBodyDraft') {
41176
- cancelPullRequestBodyDraft();
41177
- }
41178
- else if (event.type === 'startChangelogView') {
41179
- void startChangelogView();
41180
- }
41181
- else if (event.type === 'regenerateChangelog') {
41182
- regenerateChangelog();
41183
- }
41184
- else if (event.type === 'yankChangelog') {
41185
- yankChangelog();
41186
- }
41187
- else if (event.type === 'openChangelogInEditor') {
41188
- openChangelogInEditor();
41189
- }
41190
- else if (event.type === 'openComposeInEditor') {
41191
- openComposeInEditor();
41192
- }
41193
- else if (event.type === 'startCommitSplit') {
41194
- void startCommitSplit();
41195
- }
41196
- else if (event.type === 'applyCommitSplit') {
41197
- void applyCommitSplit();
41198
- }
41199
- else if (event.type === 'cancelCommitSplit') {
41200
- cancelCommitSplit();
41201
- }
41202
- else if (event.type === 'yankText') {
41203
- void yankText(event.value, event.label);
41204
- }
41205
- else if (event.type === 'runWorkflowAction') {
41206
- void runWorkflowAction(event.id, event.payload);
41207
- }
41208
- else if (event.type === 'openFileInEditor') {
41209
- openInEditor(event.path);
41210
- }
41211
- else if (event.type === 'yankFromActiveView') {
41212
- void yankFromActiveView(event.short);
41213
- }
41214
- else {
41215
- // P4.5: enrich filter-mutating actions with a precomputed
41216
- // selection snapshot so the reducer can preserve the cursor on
41217
- // the same item when it's still in the filtered result, only
41218
- // snapping to result[0] when the previously selected item drops
41219
- // out. The snapshot lives in the action so the reducer never
41220
- // needs context items.
41221
- const enriched = enrichFilterActionWithRectification(event.action, state, context);
41222
- dispatch(enriched);
41223
- }
41224
- });
41225
- });
41226
- // Layout depends on focus (sidebar grows when focused), so it's
41227
- // computed here — after state is in scope but before the render path.
41228
- const layout = getLogInkLayout({
41229
- columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
41230
- rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
41231
- sidebarFocused: state.focus === 'sidebar',
41232
- inspectorFocused: state.focus === 'detail',
41233
- helpOverlayActive: state.showHelp,
41234
- });
41235
- if (layout.tooSmall) {
41236
- return h(Box, {
41237
- flexDirection: 'column',
41238
- height: layout.rows,
41239
- paddingX: 1,
41240
- paddingY: 1,
41241
- }, 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.'));
41242
- }
41243
- // First-launch onboarding overlay (P1.3) replaces the entire UI for
41244
- // one render — any keystroke dismisses it and persists the seen-marker.
41245
- if (showOnboarding) {
41246
- return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
41247
- }
41248
- 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));
41249
- }
41250
-
41251
- /**
41252
- * Explicit color-level detection for the Ink TUI (P5.2).
41253
- *
41254
- * Chalk already approximates hex colors when the terminal can't render
41255
- * truecolor — but we want an explicit signal so the catppuccin / gruvbox
41256
- * presets (which use hex) can fall back to the ANSI-named `default` preset
41257
- * cleanly on minimal SSH sessions, instead of relying on chalk's
41258
- * heuristics. Users who set `NO_COLOR` or pick the `monochrome` preset
41259
- * still get the manual override.
41260
- *
41261
- * Levels (matching the chalk taxonomy):
41262
- * - 'mono' → no ANSI escapes at all (NO_COLOR / TERM=dumb)
41263
- * - '16' → standard 16-color ANSI palette
41264
- * - '256' → xterm-256color
41265
- * - 'truecolor' → 24-bit RGB (COLORTERM=truecolor or known terminals)
41266
- */
41267
- function getColorLevel(env = process.env) {
41268
- if (env.NO_COLOR)
41269
- return 'mono';
41270
- switch (env.FORCE_COLOR) {
41271
- case '0':
41272
- return 'mono';
41273
- case '1':
41274
- return '16';
41275
- case '2':
41276
- return '256';
41277
- case '3':
41278
- return 'truecolor';
41279
- }
41280
- const colorterm = env.COLORTERM?.toLowerCase();
41281
- if (colorterm === 'truecolor' || colorterm === '24bit') {
41282
- return 'truecolor';
41283
- }
41284
- // Modern terminal emulators that publicly advertise truecolor support.
41285
- if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
41286
- return 'truecolor';
41287
- }
41288
- if (env.WT_SESSION) {
41289
- return 'truecolor';
41290
- }
41291
- switch (env.TERM_PROGRAM) {
41292
- case 'iTerm.app':
41293
- case 'WezTerm':
41294
- case 'vscode':
41295
- case 'ghostty':
41296
- case 'Hyper':
41297
- return 'truecolor';
41298
- }
41299
- if (env.TERM === 'dumb')
41300
- return 'mono';
41301
- if (env.TERM?.includes('256color'))
41302
- return '256';
41303
- return '16';
41304
- }
41305
- 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']);
41306
- /**
41307
- * `true` when the named preset relies on hex colors that look best under
41308
- * 24-bit RGB. Used by `createLogInkTheme` to decide whether to downgrade
41309
- * to the ANSI-named `default` palette on lower-capability terminals.
41310
- */
41311
- function presetUsesTrueColor(preset) {
41312
- return preset !== undefined && TRUECOLOR_PRESETS.has(preset);
41313
- }
41314
-
41315
- const THEME_PRESET_COLORS = {
41316
- default: {
41317
- accent: 'cyan',
41318
- border: 'gray',
41319
- danger: 'red',
41320
- focusBorder: 'cyan',
41321
- gitAdded: 'green',
41322
- gitDeleted: 'red',
41323
- gitModified: 'yellow',
41324
- info: 'blue',
41325
- muted: 'gray',
41326
- selection: 'cyan',
41327
- success: 'green',
41328
- warning: 'yellow',
41329
- },
41330
- catppuccin: {
41331
- accent: '#89b4fa',
41332
- border: '#585b70',
41333
- danger: '#f38ba8',
41334
- focusBorder: '#89dceb',
41335
- gitAdded: '#a6e3a1',
41336
- gitDeleted: '#f38ba8',
41337
- gitModified: '#f9e2af',
41338
- info: '#89b4fa',
41339
- muted: '#6c7086',
41340
- selection: '#45475a',
41341
- success: '#a6e3a1',
41342
- warning: '#f9e2af',
41343
- },
41344
- gruvbox: {
41345
- accent: '#83a598',
41346
- border: '#665c54',
41347
- danger: '#fb4934',
41348
- focusBorder: '#8ec07c',
41349
- gitAdded: '#b8bb26',
41350
- gitDeleted: '#fb4934',
41351
- gitModified: '#fabd2f',
41352
- info: '#83a598',
41353
- muted: '#928374',
41354
- selection: '#504945',
41355
- success: '#b8bb26',
41356
- warning: '#fabd2f',
41357
- },
41358
- dracula: {
41359
- accent: '#bd93f9',
41360
- border: '#44475a',
41361
- danger: '#ff5555',
41362
- focusBorder: '#ff79c6',
41363
- gitAdded: '#50fa7b',
41364
- gitDeleted: '#ff5555',
41365
- gitModified: '#f1fa8c',
41366
- info: '#8be9fd',
41367
- muted: '#6272a4',
41368
- selection: '#44475a',
41369
- success: '#50fa7b',
41370
- warning: '#f1fa8c',
41371
- },
41372
- nord: {
41373
- accent: '#88c0d0',
41374
- border: '#3b4252',
41375
- danger: '#bf616a',
41376
- focusBorder: '#81a1c1',
41377
- gitAdded: '#a3be8c',
41378
- gitDeleted: '#bf616a',
41379
- gitModified: '#ebcb8b',
41380
- info: '#81a1c1',
41381
- muted: '#4c566a',
41382
- selection: '#3b4252',
41383
- success: '#a3be8c',
41384
- warning: '#ebcb8b',
41385
- },
41386
- 'solarized-dark': {
41387
- accent: '#268bd2',
41388
- border: '#073642',
41389
- danger: '#dc322f',
41390
- focusBorder: '#2aa198',
41391
- gitAdded: '#859900',
41392
- gitDeleted: '#dc322f',
41393
- gitModified: '#b58900',
41394
- info: '#268bd2',
41395
- muted: '#586e75',
41396
- selection: '#073642',
41397
- success: '#859900',
41398
- warning: '#b58900',
41399
- },
41400
- 'tokyo-night': {
41401
- accent: '#7aa2f7',
41402
- border: '#3b4261',
41403
- danger: '#f7768e',
41404
- focusBorder: '#7dcfff',
41405
- gitAdded: '#9ece6a',
41406
- gitDeleted: '#f7768e',
41407
- gitModified: '#e0af68',
41408
- info: '#7aa2f7',
41409
- muted: '#565f89',
41410
- selection: '#33467c',
41411
- success: '#9ece6a',
41412
- warning: '#e0af68',
41413
- },
41414
- 'one-dark': {
41415
- accent: '#61afef',
41416
- border: '#3e4452',
41417
- danger: '#e06c75',
41418
- focusBorder: '#56b6c2',
41419
- gitAdded: '#98c379',
41420
- gitDeleted: '#e06c75',
41421
- gitModified: '#e5c07b',
41422
- info: '#61afef',
41423
- muted: '#5c6370',
41424
- selection: '#3e4452',
41425
- success: '#98c379',
41426
- warning: '#e5c07b',
41427
- },
41428
- 'rose-pine': {
41429
- accent: '#c4a7e7',
41430
- border: '#26233a',
41431
- danger: '#eb6f92',
41432
- focusBorder: '#9ccfd8',
41433
- gitAdded: '#31748f',
41434
- gitDeleted: '#eb6f92',
41435
- gitModified: '#f6c177',
41436
- info: '#9ccfd8',
41437
- muted: '#6e6a86',
41438
- selection: '#2a273f',
41439
- success: '#31748f',
41440
- warning: '#f6c177',
41441
- },
41442
- kanagawa: {
41443
- accent: '#7e9cd8',
41444
- border: '#2a2a37',
41445
- danger: '#e82424',
41446
- focusBorder: '#7fb4ca',
41447
- gitAdded: '#76946a',
41448
- gitDeleted: '#e82424',
41449
- gitModified: '#dca561',
41450
- info: '#7e9cd8',
41451
- muted: '#727169',
41452
- selection: '#2d4f67',
41453
- success: '#76946a',
41454
- warning: '#dca561',
41455
- },
41456
- everforest: {
41457
- accent: '#a7c080',
41458
- border: '#374145',
41459
- danger: '#e67e80',
41460
- focusBorder: '#83c092',
41461
- gitAdded: '#a7c080',
41462
- gitDeleted: '#e67e80',
41463
- gitModified: '#dbbc7f',
41464
- info: '#7fbbb3',
41465
- muted: '#859289',
41466
- selection: '#374145',
41467
- success: '#a7c080',
41468
- warning: '#dbbc7f',
41469
- },
41470
- monokai: {
41471
- accent: '#66d9ef',
41472
- border: '#49483e',
41473
- danger: '#f92672',
41474
- focusBorder: '#a6e22e',
41475
- gitAdded: '#a6e22e',
41476
- gitDeleted: '#f92672',
41477
- gitModified: '#e6db74',
41478
- info: '#66d9ef',
41479
- muted: '#75715e',
41480
- selection: '#49483e',
41481
- success: '#a6e22e',
41482
- warning: '#e6db74',
41483
- },
41484
- synthwave: {
41485
- accent: '#f97e72',
41486
- border: '#34294f',
41487
- danger: '#fe4450',
41488
- focusBorder: '#36f9f6',
41489
- gitAdded: '#72f1b8',
41490
- gitDeleted: '#fe4450',
41491
- gitModified: '#fede5d',
41492
- info: '#36f9f6',
41493
- muted: '#848bbd',
41494
- selection: '#34294f',
41495
- success: '#72f1b8',
41496
- warning: '#fede5d',
41497
- },
41498
- 'ayu-dark': {
41499
- accent: '#e6b450',
41500
- border: '#11151c',
41501
- danger: '#f07178',
41502
- focusBorder: '#39bae6',
41503
- gitAdded: '#7fd962',
41504
- gitDeleted: '#f07178',
41505
- gitModified: '#e6b450',
41506
- info: '#39bae6',
41507
- muted: '#565b66',
41508
- selection: '#1a1f29',
41509
- success: '#7fd962',
41510
- warning: '#e6b450',
41511
- },
41512
- palenight: {
41513
- accent: '#82aaff',
41514
- border: '#3a3f58',
41515
- danger: '#ff5370',
41516
- focusBorder: '#89ddff',
41517
- gitAdded: '#c3e88d',
41518
- gitDeleted: '#ff5370',
41519
- gitModified: '#ffcb6b',
41520
- info: '#82aaff',
41521
- muted: '#676e95',
41522
- selection: '#3a3f58',
41523
- success: '#c3e88d',
41524
- warning: '#ffcb6b',
41525
- },
41526
- 'github-dark': {
41527
- accent: '#58a6ff',
41528
- border: '#30363d',
41529
- danger: '#f85149',
41530
- focusBorder: '#58a6ff',
41531
- gitAdded: '#3fb950',
41532
- gitDeleted: '#f85149',
41533
- gitModified: '#d29922',
41534
- info: '#58a6ff',
41535
- muted: '#8b949e',
41536
- selection: '#264f78',
41537
- success: '#3fb950',
41538
- warning: '#d29922',
41539
- },
41540
- horizon: {
41541
- accent: '#e95678',
41542
- border: '#2e303e',
41543
- danger: '#e95678',
41544
- focusBorder: '#25b0bc',
41545
- gitAdded: '#09f7a0',
41546
- gitDeleted: '#e95678',
41547
- gitModified: '#fab795',
41548
- info: '#25b0bc',
41549
- muted: '#6c6f93',
41550
- selection: '#2e303e',
41551
- success: '#09f7a0',
41552
- warning: '#fab795',
41553
- },
41554
- nightfox: {
41555
- accent: '#719cd6',
41556
- border: '#2b3b51',
41557
- danger: '#c94f6d',
41558
- focusBorder: '#63cdcf',
41559
- gitAdded: '#81b29a',
41560
- gitDeleted: '#c94f6d',
41561
- gitModified: '#dbc074',
41562
- info: '#719cd6',
41563
- muted: '#738091',
41564
- selection: '#2b3b51',
41565
- success: '#81b29a',
41566
- warning: '#dbc074',
41567
- },
41568
- carbonfox: {
41569
- accent: '#78a9ff',
41570
- border: '#353535',
41571
- danger: '#ee5396',
41572
- focusBorder: '#33b1ff',
41573
- gitAdded: '#42be65',
41574
- gitDeleted: '#ee5396',
41575
- gitModified: '#ffe97b',
41576
- info: '#78a9ff',
41577
- muted: '#7b7c7e',
41578
- selection: '#353535',
41579
- success: '#42be65',
41580
- warning: '#ffe97b',
41581
- },
41582
- 'tokyonight-storm': {
41583
- accent: '#7aa2f7',
41584
- border: '#2f334d',
41585
- danger: '#f7768e',
41586
- focusBorder: '#2ac3de',
41587
- gitAdded: '#9ece6a',
41588
- gitDeleted: '#f7768e',
41589
- gitModified: '#e0af68',
41590
- info: '#2ac3de',
41591
- muted: '#545c7e',
41592
- selection: '#2f334d',
41593
- success: '#9ece6a',
41594
- warning: '#e0af68',
41595
- },
41596
- 'catppuccin-latte': {
41597
- accent: '#1e66f5',
41598
- border: '#ccd0da',
41599
- danger: '#d20f39',
41600
- focusBorder: '#179299',
41601
- gitAdded: '#40a02b',
41602
- gitDeleted: '#d20f39',
41603
- gitModified: '#df8e1d',
41604
- info: '#1e66f5',
41605
- muted: '#9ca0b0',
41606
- selection: '#ccd0da',
41607
- success: '#40a02b',
41608
- warning: '#df8e1d',
41609
- },
41610
- 'solarized-light': {
41611
- accent: '#268bd2',
41612
- border: '#eee8d5',
41613
- danger: '#dc322f',
41614
- focusBorder: '#2aa198',
41615
- gitAdded: '#859900',
41616
- gitDeleted: '#dc322f',
41617
- gitModified: '#b58900',
41618
- info: '#268bd2',
41619
- muted: '#93a1a1',
41620
- selection: '#eee8d5',
41621
- success: '#859900',
41622
- warning: '#b58900',
41623
- },
41624
- 'github-light': {
41625
- accent: '#0969da',
41626
- border: '#d0d7de',
41627
- danger: '#cf222e',
41628
- focusBorder: '#0969da',
41629
- gitAdded: '#1a7f37',
41630
- gitDeleted: '#cf222e',
41631
- gitModified: '#9a6700',
41632
- info: '#0969da',
41633
- muted: '#656d76',
41634
- selection: '#ddf4ff',
41635
- success: '#1a7f37',
41636
- warning: '#9a6700',
41637
- },
41638
- iceberg: {
41639
- accent: '#84a0c6',
41640
- border: '#1e2132',
41641
- danger: '#e27878',
41642
- focusBorder: '#89b8c2',
41643
- gitAdded: '#b4be82',
41644
- gitDeleted: '#e27878',
41645
- gitModified: '#e2a478',
41646
- info: '#84a0c6',
41647
- muted: '#6b7089',
41648
- selection: '#1e2132',
41649
- success: '#b4be82',
41650
- warning: '#e2a478',
41651
- },
41652
- 'material-ocean': {
41653
- accent: '#82aaff',
41654
- border: '#2b2f3a',
41655
- danger: '#f07178',
41656
- focusBorder: '#89ddff',
41657
- gitAdded: '#c3e88d',
41658
- gitDeleted: '#f07178',
41659
- gitModified: '#ffcb6b',
41660
- info: '#82aaff',
41661
- muted: '#464b5d',
41662
- selection: '#2b2f3a',
41663
- success: '#c3e88d',
41664
- warning: '#ffcb6b',
41665
- },
41666
- moonlight: {
41667
- accent: '#82aaff',
41668
- border: '#2f334d',
41669
- danger: '#ff757f',
41670
- focusBorder: '#86e1fc',
41671
- gitAdded: '#c3e88d',
41672
- gitDeleted: '#ff757f',
41673
- gitModified: '#ffc777',
41674
- info: '#82aaff',
41675
- muted: '#636da6',
41676
- selection: '#2f334d',
41677
- success: '#c3e88d',
41678
- warning: '#ffc777',
41679
- },
41680
- poimandres: {
41681
- accent: '#add7ff',
41682
- border: '#1b1e28',
41683
- danger: '#d0679d',
41684
- focusBorder: '#5de4c7',
41685
- gitAdded: '#5de4c7',
41686
- gitDeleted: '#d0679d',
41687
- gitModified: '#fffac2',
41688
- info: '#add7ff',
41689
- muted: '#506477',
41690
- selection: '#1b1e28',
41691
- success: '#5de4c7',
41692
- warning: '#fffac2',
41693
- },
41694
- 'vitesse-dark': {
41695
- accent: '#4d9375',
41696
- border: '#282828',
41697
- danger: '#cb7676',
41698
- focusBorder: '#4d9375',
41699
- gitAdded: '#4d9375',
41700
- gitDeleted: '#cb7676',
41701
- gitModified: '#e6cc77',
41702
- info: '#6394bf',
41703
- muted: '#758575',
41704
- selection: '#282828',
41705
- success: '#4d9375',
41706
- warning: '#e6cc77',
41707
- },
41708
- vesper: {
41709
- accent: '#ffc799',
41710
- border: '#232323',
41711
- danger: '#f5a191',
41712
- focusBorder: '#99ffe4',
41713
- gitAdded: '#99ffe4',
41714
- gitDeleted: '#f5a191',
41715
- gitModified: '#ffc799',
41716
- info: '#a0c4ff',
41717
- muted: '#575757',
41718
- selection: '#232323',
41719
- success: '#99ffe4',
41720
- warning: '#ffc799',
41721
- },
41722
- flexoki: {
41723
- accent: '#205ea6',
41724
- border: '#343331',
41725
- danger: '#af3029',
41726
- focusBorder: '#24837b',
41727
- gitAdded: '#66800b',
41728
- gitDeleted: '#af3029',
41729
- gitModified: '#ad8301',
41730
- info: '#205ea6',
41731
- muted: '#878580',
41732
- selection: '#343331',
41733
- success: '#66800b',
41734
- warning: '#ad8301',
41735
- },
41736
- mellow: {
41737
- accent: '#7eb8da',
41738
- border: '#2a2a2a',
41739
- danger: '#f5a191',
41740
- focusBorder: '#a3d4a0',
41741
- gitAdded: '#a3d4a0',
41742
- gitDeleted: '#f5a191',
41743
- gitModified: '#f0c674',
41744
- info: '#7eb8da',
41745
- muted: '#6b6b6b',
41746
- selection: '#2a2a2a',
41747
- success: '#a3d4a0',
41748
- warning: '#f0c674',
41749
- },
41750
- };
41751
- function shouldUseAscii(term) {
41752
- if (!term) {
41753
- return false;
41754
- }
41755
- return term === 'dumb' || term.startsWith('vt100');
41756
- }
41757
- function createLogInkTheme(options = {}) {
41758
- const noColor = (options.noColor ?? Boolean(process.env.NO_COLOR)) ||
41759
- options.preset === 'monochrome';
41760
- const ascii = options.ascii ?? shouldUseAscii(options.term ?? process.env.TERM);
41761
- const requestedPreset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
41762
- // P5.2 — gracefully downgrade hex presets (catppuccin / gruvbox) when
41763
- // the host terminal can't render truecolor. Chalk approximates hex in
41764
- // those modes anyway, but the default preset's ANSI-named palette
41765
- // renders far more faithfully on 16-color terminals.
41766
- const colorLevel = getColorLevel(options.env ?? process.env);
41767
- const preset = !noColor && presetUsesTrueColor(requestedPreset) && colorLevel !== 'truecolor'
41768
- ? 'default'
41769
- : requestedPreset;
41770
- const colors = noColor
41771
- ? {}
41772
- : {
41773
- ...THEME_PRESET_COLORS[preset],
41774
- ...options.colors,
42091
+ dispatch({
42092
+ type: 'setStatus',
42093
+ value: `${target.label} target commit returned no rows — orphan ref?`,
42094
+ kind: 'warning',
42095
+ });
42096
+ }
42097
+ }
42098
+ catch (error) {
42099
+ if (mountedRef.current) {
42100
+ dispatch({
42101
+ type: 'setStatus',
42102
+ value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
42103
+ kind: 'error',
42104
+ });
42105
+ }
42106
+ }
42107
+ }, [dispatch, git]);
42108
+ React.useEffect(() => {
42109
+ loadCommitContextRef.current = loadCommitContext;
42110
+ }, [loadCommitContext]);
42111
+ // Server-side history filter (#776). When the user submits `path:foo`
42112
+ // or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
42113
+ // this effect picks up the change, re-runs `getLogRows` with merged
42114
+ // args, and replaces the rows. Clearing the fetch args (Ctrl+U inside
42115
+ // filter mode) re-fetches with the original logArgv so the user gets
42116
+ // the live full log back, not a stale snapshot of the initial rows.
42117
+ const historyFetchEffectInitialized = React.useRef(false);
42118
+ const historyFetchRequestRef = React.useRef(0);
42119
+ React.useEffect(() => {
42120
+ if (!logArgv)
42121
+ return;
42122
+ // Skip the first run — initial rows came in via deps.rows; we only
42123
+ // want to fetch in response to *changes* to historyFetchArgs.
42124
+ if (!historyFetchEffectInitialized.current) {
42125
+ historyFetchEffectInitialized.current = true;
42126
+ return;
42127
+ }
42128
+ const requestId = historyFetchRequestRef.current + 1;
42129
+ historyFetchRequestRef.current = requestId;
42130
+ const fetchArgs = state.historyFetchArgs;
42131
+ const merged = {
42132
+ ...logArgv,
42133
+ ...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
42134
+ ...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
41775
42135
  };
41776
- return {
41777
- noColor,
41778
- ascii,
41779
- borderStyle: options.borderStyle || (ascii ? 'classic' : 'round'),
41780
- colors,
41781
- };
42136
+ const description = fetchArgs?.author
42137
+ ? `author:${fetchArgs.author}`
42138
+ : fetchArgs?.path
42139
+ ? `path:${fetchArgs.path}`
42140
+ : undefined;
42141
+ dispatch({
42142
+ type: 'setStatus',
42143
+ value: description ? `Refetching with ${description}` : 'Restoring full log',
42144
+ });
42145
+ void (async () => {
42146
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
42147
+ const nextRows = await safe(getLogRows(git, merged, {
42148
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
42149
+ extraRefs: stashHashes,
42150
+ }));
42151
+ if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
42152
+ return;
42153
+ }
42154
+ if (!nextRows) {
42155
+ dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
42156
+ return;
42157
+ }
42158
+ dispatch({ type: 'replaceRows', rows: nextRows });
42159
+ const matched = getCommitRows(nextRows).length;
42160
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
42161
+ dispatch({
42162
+ type: 'setStatus',
42163
+ value: description
42164
+ ? `Showing ${matched} commits matching ${description}`
42165
+ : 'Showing full log',
42166
+ kind: 'success',
42167
+ });
42168
+ })();
42169
+ }, [dispatch, git, logArgv, state.historyFetchArgs]);
42170
+ // Graph mode toggle (`g` key, #791 follow-up). The header label flips
42171
+ // between "compact graph" and "full graph", but unless we re-fetch with
42172
+ // the right `view`, the underlying rows still come from the user's
42173
+ // initial argv (default `--first-parent --no-merges`) and the renderer
42174
+ // has no topology to draw — defeating the per-lane / junction work.
42175
+ // Mirrors the historyFetchArgs effect: skip first run, request-id ref
42176
+ // for stale-completion guard, swap rows in place via replaceRows.
42177
+ const toggleGraphEffectInitialized = React.useRef(false);
42178
+ const toggleGraphRequestRef = React.useRef(0);
42179
+ React.useEffect(() => {
42180
+ if (!logArgv)
42181
+ return;
42182
+ if (!toggleGraphEffectInitialized.current) {
42183
+ toggleGraphEffectInitialized.current = true;
42184
+ return;
42185
+ }
42186
+ const requestId = toggleGraphRequestRef.current + 1;
42187
+ toggleGraphRequestRef.current = requestId;
42188
+ const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
42189
+ dispatch({
42190
+ type: 'setStatus',
42191
+ value: state.fullGraph
42192
+ ? 'Loading full topology…'
42193
+ : 'Loading compact history…',
42194
+ });
42195
+ void (async () => {
42196
+ // Include stash commits as graph roots so the toggle's re-fetch
42197
+ // sees the same rich graph the boot loader assembles. Without
42198
+ // this, flipping `\` into full mode and back loses the stash
42199
+ // anchors that loadRowsWithStashes seeded on boot.
42200
+ const stashHashes = await getStashCommitHashes(git).catch(() => []);
42201
+ const nextRows = await safe(getLogRows(git, merged, {
42202
+ limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
42203
+ extraRefs: stashHashes,
42204
+ }));
42205
+ if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
42206
+ return;
42207
+ }
42208
+ if (!nextRows) {
42209
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
42210
+ return;
42211
+ }
42212
+ dispatch({ type: 'replaceRows', rows: nextRows });
42213
+ const matched = getCommitRows(nextRows).length;
42214
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
42215
+ dispatch({
42216
+ type: 'setStatus',
42217
+ value: state.fullGraph
42218
+ ? `Showing ${matched} commits across all branches`
42219
+ : `Showing ${matched} commits (compact)`,
42220
+ kind: 'success',
42221
+ });
42222
+ })();
42223
+ }, [dispatch, git, logArgv, state.fullGraph]);
42224
+ const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
42225
+ .map((line, index) => (line.startsWith('@@') ? index : -1))
42226
+ .filter((index) => index >= 0)), [filePreview]);
42227
+ const worktreeDirty = Boolean(context.worktree &&
42228
+ (context.worktree.stagedCount + context.worktree.unstagedCount + context.worktree.untrackedCount) > 0);
42229
+ useInput((inputValue, key) => {
42230
+ // First-launch onboarding (P1.3): any keystroke dismisses the overlay
42231
+ // and writes the seen-marker. Swallow the keystroke so the same key
42232
+ // doesn't also trigger normal input dispatch.
42233
+ if (showOnboarding) {
42234
+ setShowOnboarding(false);
42235
+ markOnboardingSeen();
42236
+ return;
42237
+ }
42238
+ // P4.5: navigation in branches/tags/stash uses the FILTERED list
42239
+ // length when a filter is active so j/k stay live instead of getting
42240
+ // stuck against a full-list count that no longer matches what's on
42241
+ // screen. The filtered lists are memoized at LogInkApp scope (#808
42242
+ // perf pass) — reading them here is O(1) instead of O(branches +
42243
+ // tags + stashes + worktrees) per keystroke.
42244
+ const branchVisibleCount = filteredBranchList.length;
42245
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
42246
+ const tagVisibleCount = filteredTagList.length;
42247
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
42248
+ const stashVisibleCount = filteredStashList.length;
42249
+ const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
42250
+ const reflogVisibleCount = filteredReflogList.length;
42251
+ const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
42252
+ const submoduleVisibleCount = filteredSubmoduleList.length;
42253
+ filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
42254
+ const issueVisibleCount = filteredIssueList.length;
42255
+ const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
42256
+ const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
42257
+ const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
42258
+ const worktreeVisibleCount = filteredWorktreeList.length;
42259
+ // When the diff view is showing a stash patch, swap the previewLineCount
42260
+ // to the stash diff length so the existing pageDetailPreview path
42261
+ // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
42262
+ const diffPreviewLineCount = state.diffSource === 'stash'
42263
+ ? stashDiffLines?.length
42264
+ : filePreview?.hunks.length;
42265
+ // Per-file segmentation for stash diffs reads the LogInkApp-scoped
42266
+ // memo so navigation keys + the input-context derivation share a
42267
+ // single parse pass per stash patch instead of re-walking the
42268
+ // entire patch text on every keystroke.
42269
+ const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
42270
+ const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
42271
+ const stashDiffSelectedPath = state.diffSource === 'stash'
42272
+ ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
42273
+ : undefined;
42274
+ getLogInkInputEvents(state, inputValue, key, {
42275
+ detailFileCount: detail?.files.length,
42276
+ previewLineCount: diffPreviewLineCount,
42277
+ worktreeDiffLineCount: worktreeDiff?.lines.length,
42278
+ worktreeFileCount: visibleWorktreeFilesGrouped.length,
42279
+ worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
42280
+ commitDiffHunkOffsets,
42281
+ branchCount: branchVisibleCount,
42282
+ branchSelectedShortName,
42283
+ tagCount: tagVisibleCount,
42284
+ tagSelectedName,
42285
+ stashCount: stashVisibleCount,
42286
+ reflogCount: reflogVisibleCount,
42287
+ reflogSelectedHash,
42288
+ submoduleCount: submoduleVisibleCount,
42289
+ issueCount: issueVisibleCount,
42290
+ issueSelectedUrl,
42291
+ pullRequestTriageCount: pullRequestTriageVisibleCount,
42292
+ pullRequestTriageSelectedUrl,
42293
+ stashSelectedRef,
42294
+ stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
42295
+ stashDiffSelectedPath,
42296
+ worktreeListCount: worktreeVisibleCount,
42297
+ worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
42298
+ statusGroups: visibleWorktreeGroups.map((group) => ({
42299
+ state: group.state,
42300
+ count: group.files.length,
42301
+ startIndex: group.startIndex,
42302
+ })),
42303
+ inspectorActionCount: getInspectorActionsForState(state).length,
42304
+ commitDiffSelectedPath: state.diffSource === 'commit'
42305
+ ? selectedDetailFile?.path
42306
+ : undefined,
42307
+ commitDiffSelectedSha: state.diffSource === 'commit'
42308
+ ? selected?.hash
42309
+ : undefined,
42310
+ // #931 PR 3b — Submodule drill-in target for the cursored file
42311
+ // in a commit diff. Resolved per-render so the Enter handler in
42312
+ // `inkInput.ts` doesn't have to re-walk the submodule overview;
42313
+ // undefined whenever the cursored file isn't a registered
42314
+ // submodule (or the overview / repo root haven't loaded yet).
42315
+ commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
42316
+ ? resolveCommitDiffDrillInTarget({
42317
+ selectedFile: {
42318
+ path: selectedDetailFile.path,
42319
+ submoduleChange: filePreview?.path === selectedDetailFile.path
42320
+ ? filePreview.submoduleChange
42321
+ : undefined,
42322
+ },
42323
+ submodules: context.submodules,
42324
+ activeRepoRoot,
42325
+ })
42326
+ : undefined,
42327
+ // #931 PR 4 / #932 — Submodule drill-in target for the cursored
42328
+ // row in the dedicated submodules view. Resolved per-render so
42329
+ // the Enter handler in `inkInput.ts` doesn't have to re-walk the
42330
+ // submodule overview. Gated on `activeView === 'submodules'` so
42331
+ // a stale resolution from a different view can't accidentally
42332
+ // fire — the runtime only ever populates it when the user is
42333
+ // actually on the view.
42334
+ submoduleViewDrillIn: state.activeView === 'submodules'
42335
+ ? resolveSubmoduleViewDrillInTarget({
42336
+ selectedIndex: state.selectedSubmoduleIndex,
42337
+ submodules: context.submodules,
42338
+ activeRepoRoot,
42339
+ })
42340
+ : undefined,
42341
+ worktreeDirty,
42342
+ conflictFileCount: context.operation?.conflictedFiles.length,
42343
+ conflictSelectedPath: (() => {
42344
+ const files = context.operation?.conflictedFiles;
42345
+ if (!files || files.length === 0)
42346
+ return undefined;
42347
+ const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
42348
+ return files[clamped]?.path;
42349
+ })(),
42350
+ // H / gH need the actual diff text (not just hunk offsets) to
42351
+ // slice the cursored hunk into a `git apply` patch. Stash uses
42352
+ // the full `git stash show -p` output; commit-diff uses the
42353
+ // per-file `filePreview.hunks` array. Either way, extractDiffHunk
42354
+ // walks `@@` headers and synthesizes a fresh diff --git / --- /
42355
+ // +++ header set using the path the caller already resolved.
42356
+ diffLinesForHunkApply: state.diffSource === 'stash'
42357
+ ? stashDiffLines
42358
+ : state.diffSource === 'commit'
42359
+ ? filePreview?.hunks
42360
+ : undefined,
42361
+ // Line count of the changelog text, used by the changelog view's
42362
+ // j/k/PgUp/PgDn scroll bindings to clamp `pageChangelog` deltas.
42363
+ // Computed from view state rather than threaded through context
42364
+ // because the surface owns its own content — no external loader.
42365
+ changelogLineCount: state.changelogView.text?.split('\n').length,
42366
+ // Approximate line count for the split-plan overlay. Each group
42367
+ // renders as a header + (body if any) + files block + (rationale
42368
+ // if any) + blank separator. Used by j/k/PgUp/PgDn to clamp the
42369
+ // scroll offset. The exact render math lives in the overlay
42370
+ // module — this is a close-enough heuristic for clamping.
42371
+ // #879 item 3 — short sha of the bisect terminator (if any).
42372
+ // Gates `y`/`Y` yank on the completion panel and lets the
42373
+ // runtime resolve the value without re-parsing the log.
42374
+ bisectCompletionSha: context.bisect?.active
42375
+ ? getBisectCompletion(context.bisect.log)?.sha
42376
+ : undefined,
42377
+ // #879 item 4 — disambiguates the bisect view's `s` keystroke
42378
+ // (skip current candidate vs. start the wizard).
42379
+ bisectActive: Boolean(context.bisect?.active),
42380
+ splitPlanLineCount: state.splitPlan?.plan
42381
+ ? state.splitPlan.plan.groups.reduce((sum, group) => {
42382
+ let lines = 2; // title + separator
42383
+ if (group.body)
42384
+ lines += group.body.split('\n').length + 1;
42385
+ if (group.rationale)
42386
+ lines += 2;
42387
+ lines += (group.files?.length || 0) + 1;
42388
+ const hunkCount = group.hunks?.length || 0;
42389
+ if (hunkCount > 0)
42390
+ lines += hunkCount + 1;
42391
+ return sum + lines;
42392
+ }, 0)
42393
+ : undefined,
42394
+ }).forEach((event) => {
42395
+ if (event.type === 'exit') {
42396
+ exit();
42397
+ }
42398
+ else if (event.type === 'refreshContext') {
42399
+ void refreshContext();
42400
+ }
42401
+ else if (event.type === 'toggleSelectedFileStage') {
42402
+ void toggleSelectedFileStage();
42403
+ }
42404
+ else if (event.type === 'toggleSelectedHunkStage') {
42405
+ void toggleSelectedHunkStage();
42406
+ }
42407
+ else if (event.type === 'revertSelectedFile') {
42408
+ void revertSelectedFile();
42409
+ }
42410
+ else if (event.type === 'revertSelectedHunk') {
42411
+ void revertSelectedHunk();
42412
+ }
42413
+ else if (event.type === 'createManualCommit') {
42414
+ void createCommitFromCompose();
42415
+ }
42416
+ else if (event.type === 'runAiCommitDraft') {
42417
+ void runAiCommitDraft();
42418
+ }
42419
+ else if (event.type === 'cancelAiCommitDraft') {
42420
+ cancelAiCommitDraft();
42421
+ }
42422
+ else if (event.type === 'startCreatePullRequest') {
42423
+ void startCreatePullRequest();
42424
+ }
42425
+ else if (event.type === 'cancelPullRequestBodyDraft') {
42426
+ cancelPullRequestBodyDraft();
42427
+ }
42428
+ else if (event.type === 'startChangelogView') {
42429
+ void startChangelogView();
42430
+ }
42431
+ else if (event.type === 'regenerateChangelog') {
42432
+ regenerateChangelog();
42433
+ }
42434
+ else if (event.type === 'yankChangelog') {
42435
+ yankChangelog();
42436
+ }
42437
+ else if (event.type === 'openChangelogInEditor') {
42438
+ openChangelogInEditor();
42439
+ }
42440
+ else if (event.type === 'openComposeInEditor') {
42441
+ openComposeInEditor();
42442
+ }
42443
+ else if (event.type === 'startCommitSplit') {
42444
+ void startCommitSplit();
42445
+ }
42446
+ else if (event.type === 'applyCommitSplit') {
42447
+ void applyCommitSplit();
42448
+ }
42449
+ else if (event.type === 'cancelCommitSplit') {
42450
+ cancelCommitSplit();
42451
+ }
42452
+ else if (event.type === 'yankText') {
42453
+ void yankText(event.value, event.label);
42454
+ }
42455
+ else if (event.type === 'runWorkflowAction') {
42456
+ void runWorkflowAction(event.id, event.payload);
42457
+ }
42458
+ else if (event.type === 'openFileInEditor') {
42459
+ openInEditor(event.path);
42460
+ }
42461
+ else if (event.type === 'yankFromActiveView') {
42462
+ void yankFromActiveView(event.short);
42463
+ }
42464
+ else if (event.type === 'applyThemePreset') {
42465
+ // Apply for the session immediately, and best-effort persist to the
42466
+ // global config so it sticks across launches. The picker has already
42467
+ // dispatched `toggleThemePicker` (closing it), which clears the
42468
+ // preview via the sync effect below — the session preset takes over.
42469
+ const preset = event.preset;
42470
+ setThemeSessionPreset(preset);
42471
+ saveThemePreset(preset);
42472
+ }
42473
+ else {
42474
+ // P4.5: enrich filter-mutating actions with a precomputed
42475
+ // selection snapshot so the reducer can preserve the cursor on
42476
+ // the same item when it's still in the filtered result, only
42477
+ // snapping to result[0] when the previously selected item drops
42478
+ // out. The snapshot lives in the action so the reducer never
42479
+ // needs context items.
42480
+ const enriched = enrichFilterActionWithRectification(event.action, state, context);
42481
+ dispatch(enriched);
42482
+ }
42483
+ });
42484
+ });
42485
+ // Layout depends on focus (sidebar grows when focused), so it's
42486
+ // computed here — after state is in scope but before the render path.
42487
+ const layout = getLogInkLayout({
42488
+ columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
42489
+ rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
42490
+ sidebarFocused: state.focus === 'sidebar',
42491
+ inspectorFocused: state.focus === 'detail',
42492
+ helpOverlayActive: state.showHelp,
42493
+ });
42494
+ if (layout.tooSmall) {
42495
+ return h(Box, {
42496
+ flexDirection: 'column',
42497
+ height: layout.rows,
42498
+ paddingX: 1,
42499
+ paddingY: 1,
42500
+ }, 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.'));
42501
+ }
42502
+ // First-launch onboarding overlay (P1.3) replaces the entire UI for
42503
+ // one render — any keystroke dismisses it and persists the seen-marker.
42504
+ if (showOnboarding) {
42505
+ return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
42506
+ }
42507
+ 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));
41782
42508
  }
41783
42509
 
41784
42510
  /**
@@ -41956,6 +42682,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
41956
42682
  React,
41957
42683
  rows,
41958
42684
  theme: createLogInkTheme(options.theme),
42685
+ themeConfig: options.theme,
41959
42686
  resumeRef,
41960
42687
  });
41961
42688
  const instance = ink.render(app, getLogInkRenderOptions({ input, output, error }));
@@ -43401,7 +44128,7 @@ const options$1 = {
43401
44128
  },
43402
44129
  theme: {
43403
44130
  description: 'TUI theme preset',
43404
- 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'],
44131
+ 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'],
43405
44132
  },
43406
44133
  };
43407
44134
  const builder$1 = (yargs) => {
@@ -43429,7 +44156,7 @@ const options = {
43429
44156
  },
43430
44157
  theme: {
43431
44158
  description: 'TUI theme preset',
43432
- 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'],
44159
+ 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'],
43433
44160
  },
43434
44161
  };
43435
44162
  const builder = (yargs) => {
@@ -44132,6 +44859,9 @@ function createWorkspaceState(init) {
44132
44859
  roots: init.roots,
44133
44860
  showHelp: false,
44134
44861
  showOnboarding: Boolean(init.showOnboarding),
44862
+ showThemePicker: false,
44863
+ themePickerFilter: '',
44864
+ themePickerIndex: 0,
44135
44865
  knownRepoPaths: init.knownRepoPaths ?? [],
44136
44866
  pullRequestFetching: [],
44137
44867
  };
@@ -44302,6 +45032,39 @@ function applyWorkspaceAction(state, action) {
44302
45032
  case 'close-help': {
44303
45033
  return { ...state, showHelp: false };
44304
45034
  }
45035
+ case 'toggle-theme-picker': {
45036
+ return {
45037
+ ...state,
45038
+ showThemePicker: !state.showThemePicker,
45039
+ showHelp: false,
45040
+ showOnboarding: false,
45041
+ themePickerFilter: '',
45042
+ themePickerIndex: 0,
45043
+ };
45044
+ }
45045
+ case 'move-theme-picker': {
45046
+ return {
45047
+ ...state,
45048
+ themePickerIndex: clampCursor(state.themePickerIndex + action.delta, action.presetCount),
45049
+ };
45050
+ }
45051
+ case 'append-theme-picker-filter': {
45052
+ return {
45053
+ ...state,
45054
+ themePickerFilter: `${state.themePickerFilter}${action.value}`,
45055
+ themePickerIndex: 0,
45056
+ };
45057
+ }
45058
+ case 'backspace-theme-picker-filter': {
45059
+ return {
45060
+ ...state,
45061
+ themePickerFilter: state.themePickerFilter.slice(0, -1),
45062
+ themePickerIndex: 0,
45063
+ };
45064
+ }
45065
+ case 'clear-theme-picker-filter': {
45066
+ return { ...state, themePickerFilter: '', themePickerIndex: 0 };
45067
+ }
44305
45068
  case 'dismiss-onboarding': {
44306
45069
  return { ...state, showOnboarding: false };
44307
45070
  }
@@ -44713,33 +45476,48 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
44713
45476
  });
44714
45477
  return chips;
44715
45478
  }
44716
- // Footer hints prioritize discoverable / forgettable actions and
44717
- // drop the bindings users can find on their own (arrow keys, Enter
44718
- // for "open", Tab for "switch panels"). The full keymap lives behind
44719
- // `?` so nothing is hidden, just decluttered.
44720
- const LIST_HINT = 's sort · / filter · r/R refresh · a add · d remove · ? help · q quit';
44721
- const SIDEBAR_HINT = '? help · q quit';
44722
- const FILTER_HINT = 'type to filter · enter apply · esc cancel';
44723
- const ADD_REPO_HINT = 'type path · tab to complete · enter to add · esc to cancel';
44724
- const CONFIRM_DELETE_HINT = 'press y to remove · any other key to cancel';
44725
- function hintFor(focus) {
45479
+ // Footer hints are split into two slots — same pattern as `coco ui`:
45480
+ // - contextual : per-mode actions (sort, filter, refresh, add/remove)
45481
+ // - global : always-on essentials (help, quit) never crowded out
45482
+ //
45483
+ // The contextual slot drops bindings users can find via the help
45484
+ // overlay (arrow keys, tab); the global slot is the safety net so
45485
+ // `? help` and `q quit` never disappear.
45486
+ const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
45487
+ const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
45488
+ const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
45489
+ const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
45490
+ const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
45491
+ const GLOBAL_HINTS = ['? help', 'q quit'];
45492
+ function contextualHintsFor(focus) {
44726
45493
  switch (focus) {
44727
45494
  case 'sidebar':
44728
- return SIDEBAR_HINT;
45495
+ return SIDEBAR_CONTEXTUAL;
44729
45496
  case 'filter':
44730
- return FILTER_HINT;
45497
+ return FILTER_CONTEXTUAL;
44731
45498
  case 'add-repo':
44732
- return ADD_REPO_HINT;
45499
+ return ADD_REPO_CONTEXTUAL;
44733
45500
  case 'confirm-delete':
44734
- return CONFIRM_DELETE_HINT;
45501
+ return CONFIRM_DELETE_CONTEXTUAL;
44735
45502
  case 'list':
44736
45503
  default:
44737
- return LIST_HINT;
45504
+ return LIST_CONTEXTUAL;
44738
45505
  }
44739
45506
  }
44740
45507
  function buildWorkspaceFooter(state) {
45508
+ const contextual = contextualHintsFor(state.focus);
45509
+ // Modal modes (filter / add-repo / confirm-delete) suppress the
45510
+ // global hints — those bindings are not reachable while a prompt
45511
+ // is open and showing them would be misleading.
45512
+ const isModal = state.focus === 'filter' ||
45513
+ state.focus === 'add-repo' ||
45514
+ state.focus === 'confirm-delete';
45515
+ const global = isModal ? [] : GLOBAL_HINTS;
45516
+ const allHints = [...contextual, ...global];
44741
45517
  return {
44742
- hint: hintFor(state.focus),
45518
+ hint: allHints.join(' · '),
45519
+ contextual,
45520
+ global,
44743
45521
  status: state.status,
44744
45522
  filterMode: state.focus === 'filter',
44745
45523
  };
@@ -44749,11 +45527,26 @@ function buildWorkspaceFooter(state) {
44749
45527
  * sections so users can scan by intent ("how do I navigate?" "how
44750
45528
  * do I act?") rather than reading a flat alphabetized list.
44751
45529
  *
45530
+ * Section order mirrors `coco ui`'s help convention — Essentials
45531
+ * first so newcomers see `?`/`esc`/`q` immediately, then move outward
45532
+ * to navigation, modification, and the destructive verbs last.
45533
+ *
44752
45534
  * The view layer composes these into a panel with section titles,
44753
45535
  * a leading app/title bar, and a closing hint at the bottom.
44754
45536
  */
44755
45537
  function buildWorkspaceHelpSections() {
44756
45538
  return [
45539
+ {
45540
+ title: 'Essentials',
45541
+ subtitle: 'The keys you reach for most often.',
45542
+ rows: [
45543
+ { glyph: '?', keys: '?', description: 'Toggle this help overlay' },
45544
+ { glyph: '⎋', keys: 'esc', description: 'Clear filter / close overlay / cancel prompt' },
45545
+ { glyph: '◴', keys: 'q · ctrl+c', description: 'Quit the workspace surface' },
45546
+ { glyph: '↵', keys: 'enter', description: 'Drill into the cursored repo (coco ui)' },
45547
+ { glyph: '◧', keys: 'T', description: 'Theme picker — browse, live-preview & apply a color theme' },
45548
+ ],
45549
+ },
44757
45550
  {
44758
45551
  title: 'Navigate',
44759
45552
  subtitle: 'Move the cursor and switch focus between panels.',
@@ -44763,7 +45556,6 @@ function buildWorkspaceHelpSections() {
44763
45556
  { glyph: '←', keys: 'h', description: 'Jump focus to the sidebar' },
44764
45557
  { glyph: '→', keys: 'l', description: 'Jump focus to the list' },
44765
45558
  { glyph: '⤒', keys: 'g / G', description: 'Jump to top / bottom of the list' },
44766
- { glyph: '↵', keys: 'enter', description: 'Drill into the cursored repo (coco ui)' },
44767
45559
  ],
44768
45560
  },
44769
45561
  {
@@ -44783,14 +45575,6 @@ function buildWorkspaceHelpSections() {
44783
45575
  { glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
44784
45576
  ],
44785
45577
  },
44786
- {
44787
- title: 'General',
44788
- rows: [
44789
- { glyph: '?', keys: '?', description: 'Toggle this help overlay' },
44790
- { glyph: '⎋', keys: 'esc', description: 'Clear filter / close overlay / cancel prompt' },
44791
- { glyph: '◴', keys: 'q · ctrl+c', description: 'Quit the workspace surface' },
44792
- ],
44793
- },
44794
45578
  ];
44795
45579
  }
44796
45580
  function buildWorkspaceOnboarding(state) {
@@ -45237,13 +46021,19 @@ function renderFooter(deps) {
45237
46021
  // height shifted by a row every time a status banner came and went,
45238
46022
  // forcing the panel chrome to reflow.
45239
46023
  const statusContent = model.status ?? '';
46024
+ const contextualText = model.contextual.join(' ');
46025
+ const globalText = model.global.join(' · ');
45240
46026
  return React.createElement(Box, {
45241
46027
  borderColor: focusBorderColor(theme, false),
45242
46028
  borderStyle: theme.borderStyle,
45243
46029
  paddingX: 1,
45244
46030
  flexDirection: 'column',
45245
46031
  height: FOOTER_HEIGHT,
45246
- }, React.createElement(Text, { dimColor: true }, model.hint), React.createElement(Text, {
46032
+ },
46033
+ // Row 1: contextual ↔ global hints. justifyContent pushes them
46034
+ // to opposite edges so the eye scans each cluster as one block —
46035
+ // same shape as `coco ui`'s footer post-0.54.2 redesign.
46036
+ React.createElement(Box, { flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Text, { dimColor: true }, contextualText), React.createElement(Text, { dimColor: true }, globalText)), React.createElement(Text, {
45247
46037
  color: model.status ? toneColor('warn', theme) : undefined,
45248
46038
  dimColor: !model.status,
45249
46039
  }, statusContent || ' '));
@@ -45279,6 +46069,11 @@ function renderWorkspaceApp(deps) {
45279
46069
  if (deps.state.showHelp) {
45280
46070
  return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderHelpOverlay(deps), renderFooter(deps));
45281
46071
  }
46072
+ // Theme picker is modal too — the chrome live-previews underneath via
46073
+ // the reactive `deps.theme`, while the overlay replaces the body.
46074
+ if (deps.state.showThemePicker) {
46075
+ 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));
46076
+ }
45282
46077
  const bodyHeight = computeBodyHeight(deps);
45283
46078
  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));
45284
46079
  }
@@ -45319,6 +46114,37 @@ function resolveWorkspaceInput(input, key, state) {
45319
46114
  }
45320
46115
  return { kind: 'noop' };
45321
46116
  }
46117
+ // Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
46118
+ // move, Enter applies + persists, Esc clears the filter then closes.
46119
+ if (state.showThemePicker) {
46120
+ const filtered = filterThemePresets(state.themePickerFilter);
46121
+ if (key.escape) {
46122
+ if (state.themePickerFilter.length > 0) {
46123
+ return { kind: 'action', action: { type: 'clear-theme-picker-filter' } };
46124
+ }
46125
+ return { kind: 'action', action: { type: 'toggle-theme-picker' } };
46126
+ }
46127
+ if (key.return) {
46128
+ const selected = getThemePickerSelectionFor(state.themePickerFilter, state.themePickerIndex);
46129
+ if (!selected) {
46130
+ return { kind: 'action', action: { type: 'toggle-theme-picker' } };
46131
+ }
46132
+ return { kind: 'apply-theme', preset: selected };
46133
+ }
46134
+ if (key.upArrow || (key.ctrl && input === 'p')) {
46135
+ return { kind: 'action', action: { type: 'move-theme-picker', delta: -1, presetCount: filtered.length } };
46136
+ }
46137
+ if (key.downArrow || (key.ctrl && input === 'n')) {
46138
+ return { kind: 'action', action: { type: 'move-theme-picker', delta: 1, presetCount: filtered.length } };
46139
+ }
46140
+ if (key.backspace || key.delete) {
46141
+ return { kind: 'action', action: { type: 'backspace-theme-picker-filter' } };
46142
+ }
46143
+ if (input && !key.ctrl && !key.meta) {
46144
+ return { kind: 'action', action: { type: 'append-theme-picker-filter', value: input } };
46145
+ }
46146
+ return { kind: 'noop' };
46147
+ }
45322
46148
  if (state.focus === 'filter') {
45323
46149
  if (key.escape) {
45324
46150
  return { kind: 'action', action: { type: 'clear-filter' } };
@@ -45436,6 +46262,12 @@ function resolveWorkspaceInput(input, key, state) {
45436
46262
  if (input === '?') {
45437
46263
  return { kind: 'action', action: { type: 'toggle-help' } };
45438
46264
  }
46265
+ // `T` opens the theme picker (browse + live-preview + apply a color
46266
+ // theme). Single key here rather than `coco ui`'s gC chord since the
46267
+ // workspace keymap is flat (no g-prefix continuations).
46268
+ if (input === 'T') {
46269
+ return { kind: 'action', action: { type: 'toggle-theme-picker' } };
46270
+ }
45439
46271
  return { kind: 'noop' };
45440
46272
  }
45441
46273
 
@@ -45817,6 +46649,7 @@ async function startWorkspace(options) {
45817
46649
  ink,
45818
46650
  React,
45819
46651
  theme,
46652
+ themeConfig: options.theme,
45820
46653
  resumeRef,
45821
46654
  });
45822
46655
  // Override exitOnCtrlC. Ink's default ctrl+c handler reaches into
@@ -45982,6 +46815,17 @@ function WorkspaceInkApp(props) {
45982
46815
  // (see effect below) so idle workspaces don't burn CPU on animation
45983
46816
  // frames.
45984
46817
  const [spinnerTick, setSpinnerTick] = React.useState(0);
46818
+ // Theme picker (`T`) — reactive theme so the chrome live-previews the
46819
+ // cursored theme. `themePreviewPreset` follows the picker cursor while
46820
+ // open; `themeSessionPreset` is the applied choice. The effective theme
46821
+ // rebuilds from the original config; when neither is set we use the
46822
+ // static `props.theme` unchanged (mirrors `coco ui`).
46823
+ const [themePreviewPreset, setThemePreviewPreset] = React.useState(undefined);
46824
+ const [themeSessionPreset, setThemeSessionPreset] = React.useState(undefined);
46825
+ const effectiveThemePreset = themePreviewPreset ?? themeSessionPreset;
46826
+ const theme = React.useMemo(() => effectiveThemePreset
46827
+ ? createLogInkTheme({ ...props.themeConfig, preset: effectiveThemePreset })
46828
+ : props.theme, [effectiveThemePreset, props.themeConfig, props.theme]);
45985
46829
  const dispatch = React.useCallback((action) => {
45986
46830
  setState((prev) => applyWorkspaceAction(prev, action));
45987
46831
  }, []);
@@ -46356,6 +47200,13 @@ function WorkspaceInkApp(props) {
46356
47200
  case 'action':
46357
47201
  dispatch(intent.action);
46358
47202
  break;
47203
+ case 'apply-theme':
47204
+ // Apply for the session + persist to the global config (best-effort),
47205
+ // then close the picker (clearing the preview via the sync effect).
47206
+ setThemeSessionPreset(intent.preset);
47207
+ saveThemePreset(intent.preset);
47208
+ dispatch({ type: 'toggle-theme-picker' });
47209
+ break;
46359
47210
  case 'quit':
46360
47211
  workspaceDebug('→ exit() called from quit intent');
46361
47212
  exitRefHolder.current.current = { kind: 'quit' };
@@ -46405,11 +47256,20 @@ function WorkspaceInkApp(props) {
46405
47256
  filter: state.filter,
46406
47257
  });
46407
47258
  }, [props.roots, state.sortMode, state.tab, state.filter]);
47259
+ // Keep the live preview in sync with the preset under the picker cursor
47260
+ // while the overlay is open; clear it on close so the theme reverts to
47261
+ // the applied session preset (or the original config theme).
47262
+ const themePickerSelection = state.showThemePicker
47263
+ ? getThemePickerSelectionFor(state.themePickerFilter, state.themePickerIndex)
47264
+ : undefined;
47265
+ React.useEffect(() => {
47266
+ setThemePreviewPreset(state.showThemePicker ? themePickerSelection : undefined);
47267
+ }, [state.showThemePicker, themePickerSelection]);
46408
47268
  return renderWorkspaceApp({
46409
47269
  React,
46410
47270
  ink: { Box: ink.Box, Text: ink.Text },
46411
47271
  state,
46412
- theme: props.theme,
47272
+ theme,
46413
47273
  appLabel: props.appLabel,
46414
47274
  filterDraft,
46415
47275
  addRepoDraft,
@@ -46420,8 +47280,20 @@ function WorkspaceInkApp(props) {
46420
47280
  });
46421
47281
  }
46422
47282
 
46423
- const DEFAULT_ROOTS = ['~/code'];
46424
- function resolveWorkspaceRoots(argv, config) {
47283
+ /**
47284
+ * Resolve the directories the workspace scans for git repos, in
47285
+ * precedence order:
47286
+ * 1. `--root` CLI flag(s)
47287
+ * 2. `workspace.roots` from config
47288
+ * 3. the current working directory (`cwd`)
47289
+ *
47290
+ * Falling back to `cwd` (rather than a hardcoded `~/code`) means a bare
47291
+ * `coco` / `coco ws` discovers repos wherever you launched it — the
47292
+ * handler chdir's to honor `--repo` / `--cwd` before this runs, so `cwd`
47293
+ * already reflects the targeted directory. `cwd` is a parameter (not a
47294
+ * direct `process.cwd()` call) so the resolver stays a pure unit.
47295
+ */
47296
+ function resolveWorkspaceRoots(argv, config, cwd = process.cwd()) {
46425
47297
  const raw = argv.root;
46426
47298
  if (Array.isArray(raw) && raw.length > 0) {
46427
47299
  return raw.map((entry) => String(entry));
@@ -46432,7 +47304,7 @@ function resolveWorkspaceRoots(argv, config) {
46432
47304
  if (config.workspace?.roots && config.workspace.roots.length > 0) {
46433
47305
  return [...config.workspace.roots];
46434
47306
  }
46435
- return [...DEFAULT_ROOTS];
47307
+ return [cwd];
46436
47308
  }
46437
47309
  function resolveWorkspaceKnownRepos(config) {
46438
47310
  return config.workspace?.knownRepos ? [...config.workspace.knownRepos] : [];
@@ -46546,6 +47418,148 @@ var workspace = {
46546
47418
  options,
46547
47419
  };
46548
47420
 
47421
+ /**
47422
+ * Default-command router for `coco` invoked with no positional
47423
+ * arguments. Decides where to send the user based on the state of
47424
+ * their machine:
47425
+ *
47426
+ * ┌─────────────────────────┬─────────────────────┬──────────────┐
47427
+ * │ Config present? │ In a git repo? │ Action │
47428
+ * ├─────────────────────────┼─────────────────────┼──────────────┤
47429
+ * │ No (default-only) │ — │ run `init` │
47430
+ * │ Yes │ Yes (worktree) │ run `ui` │
47431
+ * │ Yes │ No │ run `ws` │
47432
+ * └─────────────────────────┴─────────────────────┴──────────────┘
47433
+ *
47434
+ * The pre-existing default — fall through to `commit` — was hostile
47435
+ * to first-time users: a fresh install with no config landed
47436
+ * straight in the API-key error path, with no hint that `coco init`
47437
+ * was the right next step. Routing fresh installs to `init` and
47438
+ * configured users to the workstation/UI matches what every other
47439
+ * git-aware CLI does (lazygit, tig, gitui all open their TUI on bare
47440
+ * invocation).
47441
+ *
47442
+ * `coco commit` keeps its dedicated subcommand entry so existing
47443
+ * scripts (`git aliases`, hook integrations, CI jobs) that call
47444
+ * `coco commit` continue to work unchanged.
47445
+ *
47446
+ * The router is a thin shim — it forwards to the existing handlers
47447
+ * via their public exports rather than re-implementing the logic.
47448
+ */
47449
+ /**
47450
+ * Pure decision function — given probed signals (whether config
47451
+ * exists, whether the current directory is a git repo, whether the
47452
+ * user opted into legacy commit-by-default), decides which command
47453
+ * to invoke. Kept pure so unit tests can cover every quadrant of
47454
+ * the router table without spawning processes.
47455
+ *
47456
+ * "Config exists" is defined as: the loader detected at least one
47457
+ * source beyond `default` — i.e., the user has either a project
47458
+ * config, a git config `[coco]` section, an env var, or an XDG
47459
+ * config. A pure-defaults run is treated as "never been configured"
47460
+ * because `coco init` is the only way to populate any of those
47461
+ * sources.
47462
+ */
47463
+ function decideDefaultRoute(input) {
47464
+ if (input.envOverride === 'commit' || input.explicitCommit) {
47465
+ return {
47466
+ kind: 'commit',
47467
+ reason: input.envOverride === 'commit' ? 'env-override' : 'explicit-flag',
47468
+ };
47469
+ }
47470
+ if (!input.hasConfigSource) {
47471
+ return { kind: 'init', reason: 'no-config' };
47472
+ }
47473
+ if (input.isGitRepo) {
47474
+ return { kind: 'ui', reason: 'config-and-repo' };
47475
+ }
47476
+ return { kind: 'workspace', reason: 'config-no-repo' };
47477
+ }
47478
+ /**
47479
+ * Probe whether the cwd (after `--repo` is honored) is inside a git
47480
+ * worktree. Tolerant of every error class — a thrown simple-git
47481
+ * call should never block the router; it should fall back to
47482
+ * "not a repo" so the user lands somewhere sensible (workspace
47483
+ * surface) rather than crashing on an empty machine.
47484
+ */
47485
+ async function probeIsGitRepo() {
47486
+ try {
47487
+ // Lazy-import simple-git so the cold-start path stays fast for
47488
+ // users running `coco --help` / `coco doctor` etc.
47489
+ const { default: simpleGit } = await import('simple-git');
47490
+ const git = simpleGit();
47491
+ return await git.checkIsRepo();
47492
+ }
47493
+ catch {
47494
+ return false;
47495
+ }
47496
+ }
47497
+ /**
47498
+ * Build a synthetic argv for one of the targeted handlers. Each
47499
+ * handler reads its own typed argv contract (`CommitArgv`,
47500
+ * `InitArgv`, `UiArgv`, `WorkspaceArgv`) so we can't just spread the
47501
+ * raw default argv — we have to project the shared fields and let
47502
+ * the handler fill in command-specific defaults.
47503
+ */
47504
+ function buildSyntheticArgv(argv) {
47505
+ return {
47506
+ _: ['$0'],
47507
+ $0: argv.$0,
47508
+ repo: argv.repo,
47509
+ cwd: argv.cwd,
47510
+ verbose: argv.verbose ?? false,
47511
+ interactive: true,
47512
+ version: false,
47513
+ help: false,
47514
+ };
47515
+ }
47516
+ /**
47517
+ * Default-command handler installed under yargs's `$0` slot. Probes
47518
+ * the environment, computes the right route, and forwards to the
47519
+ * matching command handler. Falls through to commit if any
47520
+ * unexpected error blocks routing — preserves backwards-compat
47521
+ * for users on weird setups while still giving newcomers the
47522
+ * onboarding path they deserve.
47523
+ */
47524
+ const defaultRouteHandler = async (argv, logger) => {
47525
+ // The `--repo` flag has to land before any probe runs — otherwise
47526
+ // we'd sniff the launcher's cwd instead of the targeted repo.
47527
+ applyRepoCwd(argv);
47528
+ // Trigger a config load so `getConfigSources()` returns the active
47529
+ // source list. We discard the config object — the decision only
47530
+ // cares about which sources contributed.
47531
+ void loadConfig(argv);
47532
+ const sources = getConfigSources();
47533
+ const hasConfigSource = sources.some((source) => source.source !== 'default');
47534
+ const isGitRepo = await probeIsGitRepo();
47535
+ const decision = decideDefaultRoute({
47536
+ hasConfigSource,
47537
+ isGitRepo,
47538
+ explicitCommit: Boolean(argv.commit),
47539
+ envOverride: process.env.COCO_DEFAULT,
47540
+ });
47541
+ switch (decision.kind) {
47542
+ case 'init':
47543
+ // Friendly hint before the wizard kicks in — sets expectations
47544
+ // that the user is being walked through setup, not silently
47545
+ // routed to a different command.
47546
+ logger.log('No coco config detected — running `coco init` to set up your provider + key.', { color: 'cyan' });
47547
+ logger.log('');
47548
+ await handler$7(buildSyntheticArgv(argv), logger);
47549
+ return;
47550
+ case 'ui':
47551
+ await handler$5(buildSyntheticArgv(argv));
47552
+ return;
47553
+ case 'workspace':
47554
+ await handler(buildSyntheticArgv(argv));
47555
+ return;
47556
+ case 'commit':
47557
+ default:
47558
+ await handler$9(buildSyntheticArgv(argv), logger);
47559
+ return;
47560
+ }
47561
+ };
47562
+
46549
47563
  var types = /*#__PURE__*/Object.freeze({
46550
47564
  __proto__: null
46551
47565
  });
@@ -46564,7 +47578,37 @@ y.option('repo', {
46564
47578
  description: 'Target a specific repository directory instead of the current working directory.',
46565
47579
  global: true,
46566
47580
  });
46567
- y.command([commit.command, '$0'], commit.desc, commit.builder, commit.handler);
47581
+ // Global `--verbose` (alias `-v`) every subcommand inherits it.
47582
+ // Flips `argv.verbose: true` so `commandExecutor` and `Logger` print
47583
+ // stack traces / debug spans. Previously only settable via the
47584
+ // `COCO_VERBOSE=true` env var or `coco.verbose` git/json config —
47585
+ // `BaseArgvOptions.verbose` was typed but never declared as a yargs
47586
+ // option, so passing `--verbose` from the CLI was a silent no-op.
47587
+ y.option('verbose', {
47588
+ type: 'boolean',
47589
+ alias: 'v',
47590
+ description: 'Print verbose diagnostic output (stack traces on errors, debug spans).',
47591
+ default: false,
47592
+ global: true,
47593
+ });
47594
+ // `$0` (no positional args) routes through the smart default router
47595
+ // rather than aliasing directly to `coco commit`. The router probes
47596
+ // the user's environment (config presence, git-repo presence) and
47597
+ // forwards to `init` / `ui` / `workspace` / `commit` based on which
47598
+ // of those is most likely to be helpful. Mirrors what other modern
47599
+ // git-aware CLIs do (lazygit / tig / gitui) — fresh installs land in
47600
+ // a setup wizard, configured users land in the TUI, scripts that
47601
+ // rely on `coco commit` keep their dedicated subcommand entry.
47602
+ y.command('$0', 'Smart entry point — routes to init / ui / workspace / commit based on your environment.', (yargs) => yargs.option('commit', {
47603
+ type: 'boolean',
47604
+ description: 'Force the legacy default — run `coco commit` regardless of routing.',
47605
+ default: false,
47606
+ }),
47607
+ // `commandExecutor` wraps every command with config loading, error
47608
+ // formatting, and exit-code handling. The router is a regular
47609
+ // command so it lights up the same plumbing for free.
47610
+ commandExecutor(defaultRouteHandler));
47611
+ y.command(commit.command, commit.desc, commit.builder, commit.handler);
46568
47612
  y.command(changelog.command, changelog.desc, changelog.builder, changelog.handler);
46569
47613
  y.command(recap.command, recap.desc, recap.builder, recap.handler);
46570
47614
  y.command(review.command, review.desc, review.builder, review.handler);