git-coco 0.58.1 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.58.1";
81
+ const BUILD_VERSION = "0.59.1";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -1164,6 +1164,11 @@ const schema$1 = {
1164
1164
  "type": "boolean",
1165
1165
  "description": "Group adjacent commits in the history surface under shared section headers (`── Today ──`, `── Yesterday ──`, `── April 2026 ──`) and drop the per-row date column in favor of the headers. On by default because the bucketed view gives stronger temporal orientation at a glance and the freed cells go to the commit subject. Flip off if you prefer a date column on every row.\n\nBucketing automatically suppresses itself while a search filter is active (results aren't chronological), regardless of this setting.",
1166
1166
  "default": true
1167
+ },
1168
+ "syntaxHighlight": {
1169
+ "type": "boolean",
1170
+ "description": "Syntax-highlight code in the diff view using tree-sitter (TypeScript / TSX / JavaScript today). On by default. Highlighting degrades gracefully — unsupported languages, non-ASCII lines, and parse failures fall back to the plain add/remove coloring — so the only reason to disable it is preference or a very low-color terminal. Set to `false` to opt out.",
1171
+ "default": true
1167
1172
  }
1168
1173
  },
1169
1174
  "additionalProperties": false,
@@ -2162,6 +2167,31 @@ const schema$1 = {
2162
2167
  },
2163
2168
  "warning": {
2164
2169
  "type": "string"
2170
+ },
2171
+ "syntaxKeyword": {
2172
+ "type": "string",
2173
+ "description": "Optional syntax-highlight token colors for the diff view (#1117 follow-up). All optional: when a slot is unset the resolver (`resolveSyntaxColor`) falls back to a sensible ANSI default, so themes get highlighting for free and only need to define these to customize. `noColor` themes skip syntax coloring entirely."
2174
+ },
2175
+ "syntaxString": {
2176
+ "type": "string"
2177
+ },
2178
+ "syntaxComment": {
2179
+ "type": "string"
2180
+ },
2181
+ "syntaxNumber": {
2182
+ "type": "string"
2183
+ },
2184
+ "syntaxType": {
2185
+ "type": "string"
2186
+ },
2187
+ "syntaxFunction": {
2188
+ "type": "string"
2189
+ },
2190
+ "syntaxConstant": {
2191
+ "type": "string"
2192
+ },
2193
+ "syntaxProperty": {
2194
+ "type": "string"
2165
2195
  }
2166
2196
  },
2167
2197
  "additionalProperties": false
@@ -21726,6 +21756,69 @@ function isLogInkContextKeyLoading(status, key) {
21726
21756
  return status[key] === 'loading';
21727
21757
  }
21728
21758
 
21759
+ /**
21760
+ * Derive a short menu of sensible `.gitignore` patterns from the path of
21761
+ * the cursored worktree file (the "add to .gitignore" quick-pick, `i` on
21762
+ * the status view).
21763
+ *
21764
+ * The goal is to turn the common asks — "ignore exactly this", "ignore
21765
+ * everything with this extension", "ignore this whole folder" — into
21766
+ * one-keystroke choices, while always offering a `Custom pattern…` escape
21767
+ * hatch that opens a free-text prompt for anything the menu doesn't cover
21768
+ * (negations, globs, anchored paths, etc.).
21769
+ *
21770
+ * Pure / synchronous so it's trivially unit-testable and reusable from the
21771
+ * reducer, the input handler, and the overlay renderer without pulling in
21772
+ * `fs`.
21773
+ */
21774
+ /**
21775
+ * Build the option list for a repo-relative path. Git reports untracked
21776
+ * directories with a trailing slash (`.www/`), which is how we tell a
21777
+ * directory from a file. Duplicate patterns are collapsed (e.g. a
21778
+ * top-level dir whose anchored and bare forms would otherwise repeat).
21779
+ */
21780
+ function deriveGitignoreOptions(rawPath) {
21781
+ const input = rawPath.trim();
21782
+ const options = [];
21783
+ const seen = new Set();
21784
+ const add = (pattern, label) => {
21785
+ if (!pattern || seen.has(pattern))
21786
+ return;
21787
+ seen.add(pattern);
21788
+ options.push({ pattern, label, custom: false });
21789
+ };
21790
+ if (input) {
21791
+ const isDir = input.endsWith('/');
21792
+ const clean = input.replace(/\/+$/, '');
21793
+ const segments = clean.split('/').filter(Boolean);
21794
+ const base = segments[segments.length - 1] || clean;
21795
+ const parent = segments.slice(0, -1).join('/');
21796
+ if (isDir) {
21797
+ // Anchored to the repo root vs. matching any folder of that name.
21798
+ add(`/${clean}/`, `This folder only (/${clean}/)`);
21799
+ add(`${base}/`, `Any “${base}/” folder`);
21800
+ }
21801
+ else {
21802
+ add(input, `This file only (${input})`);
21803
+ const dot = base.lastIndexOf('.');
21804
+ if (dot > 0 && dot < base.length - 1) {
21805
+ const ext = base.slice(dot);
21806
+ add(`*${ext}`, `All ${ext} files (*${ext})`);
21807
+ }
21808
+ if (parent) {
21809
+ add(`${parent}/`, `Its folder (${parent}/)`);
21810
+ }
21811
+ add(base, `Any file named “${base}”`);
21812
+ }
21813
+ }
21814
+ options.push({
21815
+ pattern: input,
21816
+ label: 'Custom pattern…',
21817
+ custom: true,
21818
+ });
21819
+ return options;
21820
+ }
21821
+
21729
21822
  /**
21730
21823
  * Extract a single hunk from a unified-patch diff so it can be fed to
21731
21824
  * `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
@@ -22865,6 +22958,27 @@ const LOG_INK_KEY_BINDINGS = [
22865
22958
  description: 'Browse, live-preview, and apply a color theme.',
22866
22959
  contexts: ['normal'],
22867
22960
  },
22961
+ {
22962
+ id: 'openProjectConfig',
22963
+ keys: ['gk'],
22964
+ label: 'project config',
22965
+ description: 'Open this repo’s .coco.json in $EDITOR (creates a starter file if missing).',
22966
+ contexts: ['normal'],
22967
+ },
22968
+ {
22969
+ id: 'openGlobalConfig',
22970
+ keys: ['gK'],
22971
+ label: 'global config',
22972
+ description: 'Open ~/.config/coco/config.json in $EDITOR (creates a starter file if missing).',
22973
+ contexts: ['normal'],
22974
+ },
22975
+ {
22976
+ id: 'gitignoreFile',
22977
+ keys: ['i'],
22978
+ label: 'gitignore',
22979
+ description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
22980
+ contexts: ['status'],
22981
+ },
22868
22982
  {
22869
22983
  id: 'viewChangelog',
22870
22984
  keys: ['L'],
@@ -22955,6 +23069,9 @@ const BINDING_CATEGORY_BY_ID = {
22955
23069
  help: 'essentials',
22956
23070
  commandPalette: 'essentials',
22957
23071
  themePicker: 'view',
23072
+ openProjectConfig: 'view',
23073
+ openGlobalConfig: 'view',
23074
+ gitignoreFile: 'mutate',
22958
23075
  quit: 'essentials',
22959
23076
  refresh: 'essentials',
22960
23077
  navigateBack: 'essentials',
@@ -23277,7 +23394,7 @@ function getLogInkFooterHints(options) {
23277
23394
  }
23278
23395
  if (options.activeView === 'status') {
23279
23396
  return {
23280
- contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'e/c compose', 'y yank'],
23397
+ contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
23281
23398
  global: NORMAL_GLOBAL_HINTS,
23282
23399
  };
23283
23400
  }
@@ -25863,6 +25980,29 @@ function applyLogInkAction(state, action) {
25863
25980
  themePickerIndex: 0,
25864
25981
  pendingKey: undefined,
25865
25982
  };
25983
+ case 'openGitignorePicker':
25984
+ return {
25985
+ ...state,
25986
+ gitignorePicker: { file: action.file, index: 0 },
25987
+ pendingKey: undefined,
25988
+ };
25989
+ case 'closeGitignorePicker':
25990
+ return {
25991
+ ...state,
25992
+ gitignorePicker: undefined,
25993
+ pendingKey: undefined,
25994
+ };
25995
+ case 'moveGitignorePicker':
25996
+ return state.gitignorePicker
25997
+ ? {
25998
+ ...state,
25999
+ gitignorePicker: {
26000
+ ...state.gitignorePicker,
26001
+ index: clampIndex(state.gitignorePicker.index + action.delta, action.count),
26002
+ },
26003
+ pendingKey: undefined,
26004
+ }
26005
+ : state;
25866
26006
  case 'setChangelogLoading':
25867
26007
  return {
25868
26008
  ...state,
@@ -26600,6 +26740,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
26600
26740
  // Palette closes on execute (toggleCommandPalette runs first), then
26601
26741
  // this opens the theme picker.
26602
26742
  return [action({ type: 'toggleThemePicker' })];
26743
+ case 'openProjectConfig':
26744
+ return [{ type: 'openConfigInEditor', scope: 'project' }];
26745
+ case 'openGlobalConfig':
26746
+ return [{ type: 'openConfigInEditor', scope: 'global' }];
26747
+ case 'gitignoreFile':
26748
+ // Runtime resolves the cursored worktree file and opens the picker
26749
+ // (no-ops with a warning when there's no file under the cursor).
26750
+ return [{ type: 'openGitignorePicker' }];
26603
26751
  case 'workflowDeleteBranch':
26604
26752
  case 'workflowDeleteTag':
26605
26753
  case 'workflowDropStash':
@@ -26693,6 +26841,12 @@ function submitInputPrompt(state) {
26693
26841
  if (!value) {
26694
26842
  return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
26695
26843
  }
26844
+ if (state.inputPrompt.kind === 'gitignore-pattern') {
26845
+ return [
26846
+ { type: 'runWorkflowAction', id: 'add-to-gitignore', payload: value },
26847
+ action({ type: 'closeInputPrompt' }),
26848
+ ];
26849
+ }
26696
26850
  if (state.inputPrompt.kind === 'reset-mode') {
26697
26851
  const mode = value.toLowerCase();
26698
26852
  if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
@@ -27171,6 +27325,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27171
27325
  }
27172
27326
  return [];
27173
27327
  }
27328
+ if (state.gitignorePicker) {
27329
+ const options = deriveGitignoreOptions(state.gitignorePicker.file);
27330
+ if (key.escape) {
27331
+ return [action({ type: 'closeGitignorePicker' })];
27332
+ }
27333
+ if (key.upArrow || (key.ctrl && inputValue === 'p')) {
27334
+ return [action({ type: 'moveGitignorePicker', delta: -1, count: options.length })];
27335
+ }
27336
+ if (key.downArrow || (key.ctrl && inputValue === 'n')) {
27337
+ return [action({ type: 'moveGitignorePicker', delta: 1, count: options.length })];
27338
+ }
27339
+ if (key.return) {
27340
+ const selected = options[Math.max(0, Math.min(state.gitignorePicker.index, options.length - 1))];
27341
+ if (!selected) {
27342
+ return [action({ type: 'closeGitignorePicker' })];
27343
+ }
27344
+ if (selected.custom) {
27345
+ // Hand off to a free-text prompt seeded with the file path so
27346
+ // the user can type any valid gitignore pattern (negations,
27347
+ // globs, anchored paths) the derived options don't cover.
27348
+ return [
27349
+ action({ type: 'closeGitignorePicker' }),
27350
+ action({
27351
+ type: 'openInputPrompt',
27352
+ kind: 'gitignore-pattern',
27353
+ label: `.gitignore pattern (e.g. ${selected.pattern || '*.log'})`,
27354
+ initial: selected.pattern,
27355
+ }),
27356
+ ];
27357
+ }
27358
+ return [
27359
+ action({ type: 'closeGitignorePicker' }),
27360
+ { type: 'runWorkflowAction', id: 'add-to-gitignore', payload: selected.pattern },
27361
+ ];
27362
+ }
27363
+ // Consume everything else so the underlying status view keys don't
27364
+ // leak through while the picker owns the screen.
27365
+ return [];
27366
+ }
27174
27367
  if (state.showCommandPalette) {
27175
27368
  const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
27176
27369
  if (key.escape) {
@@ -27448,6 +27641,20 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27448
27641
  action({ type: 'toggleThemePicker' }),
27449
27642
  ];
27450
27643
  }
27644
+ // gk — open this repo's project config (.coco.json) in $EDITOR.
27645
+ if (state.pendingKey === 'g' && inputValue === 'k') {
27646
+ return [
27647
+ action({ type: 'setPendingKey', value: undefined }),
27648
+ { type: 'openConfigInEditor', scope: 'project' },
27649
+ ];
27650
+ }
27651
+ // gK — open the global config (~/.config/coco/config.json) in $EDITOR.
27652
+ if (state.pendingKey === 'g' && inputValue === 'K') {
27653
+ return [
27654
+ action({ type: 'setPendingKey', value: undefined }),
27655
+ { type: 'openConfigInEditor', scope: 'global' },
27656
+ ];
27657
+ }
27451
27658
  // #784 — bisect view action keys. Scoped to `state.activeView ===
27452
27659
  // 'bisect' && state.focus === 'commits'` so the single-letter keys
27453
27660
  // stay free everywhere else. `g` and `b` collide with the global
@@ -28529,6 +28736,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28529
28736
  if (inputValue === 'o' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
28530
28737
  return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
28531
28738
  }
28739
+ // `i` opens the "add to .gitignore" picker for the cursored worktree
28740
+ // file. The runtime resolves the path + opens the picker (the bare
28741
+ // event carries no path — same selection-resolution pattern as the
28742
+ // revert / stage events).
28743
+ if (inputValue === 'i' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
28744
+ return [{ type: 'openGitignorePicker' }];
28745
+ }
28532
28746
  if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'worktree' && context.worktreeSelectedPath) {
28533
28747
  return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
28534
28748
  }
@@ -30206,6 +30420,493 @@ function pullBranch(git, branch, currentBranchName) {
30206
30420
  ]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
30207
30421
  }
30208
30422
 
30423
+ /**
30424
+ * Append a pattern to the repository's `.gitignore` (the runtime side of
30425
+ * the "add to .gitignore" quick-pick, `i` on the status view).
30426
+ *
30427
+ * Kept separate from the pure pattern-derivation helper
30428
+ * (`workstation/chrome/gitignore.ts`) because this touches the filesystem
30429
+ * and resolves the repo root via git — neither of which the UI layer
30430
+ * should pull in.
30431
+ */
30432
+ /**
30433
+ * Append `pattern` to `<repoRoot>/.gitignore`, creating the file if it
30434
+ * doesn't exist. No-ops (reporting success) when the exact pattern is
30435
+ * already present so re-running is safe. Handles the missing-trailing-
30436
+ * newline case so we never glue the new entry onto the previous line.
30437
+ */
30438
+ async function addToGitignore(git, pattern) {
30439
+ const entry = pattern.trim();
30440
+ if (!entry) {
30441
+ return { ok: false, message: 'No pattern to add.' };
30442
+ }
30443
+ let root;
30444
+ try {
30445
+ root = (await git.revparse(['--show-toplevel'])).trim();
30446
+ }
30447
+ catch {
30448
+ return { ok: false, message: 'Could not resolve the repository root.' };
30449
+ }
30450
+ if (!root) {
30451
+ return { ok: false, message: 'Could not resolve the repository root.' };
30452
+ }
30453
+ const file = path__namespace.join(root, '.gitignore');
30454
+ let existing = '';
30455
+ try {
30456
+ existing = await fs.promises.readFile(file, 'utf8');
30457
+ }
30458
+ catch {
30459
+ // No .gitignore yet — we'll create it.
30460
+ existing = '';
30461
+ }
30462
+ // Already ignored (exact line match, ignoring surrounding whitespace)?
30463
+ const alreadyPresent = existing
30464
+ .split('\n')
30465
+ .some((line) => line.trim() === entry);
30466
+ if (alreadyPresent) {
30467
+ return { ok: true, message: `${entry} is already in .gitignore` };
30468
+ }
30469
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith('\n');
30470
+ const addition = `${needsLeadingNewline ? '\n' : ''}${entry}\n`;
30471
+ try {
30472
+ await fs.promises.appendFile(file, addition, 'utf8');
30473
+ }
30474
+ catch (error) {
30475
+ return { ok: false, message: error.message };
30476
+ }
30477
+ return { ok: true, message: `Added ${entry} to .gitignore` };
30478
+ }
30479
+
30480
+ /**
30481
+ * Embedded tree-sitter highlight queries (one per language).
30482
+ *
30483
+ * We ship our own compact queries rather than reading the upstream
30484
+ * `queries/highlights.scm` from the grammar packages because those are
30485
+ * dev-only / not present in a published install, and they lean on
30486
+ * `#match?` / `#is-not? local` predicates that web-tree-sitter's
30487
+ * `Query.captures()` does NOT evaluate for us — including them would
30488
+ * mis-tag every identifier. These subsets are **predicate-free** (so
30489
+ * every capture is unconditionally correct) and use only grammar-valid
30490
+ * node/token names (so the query compiles — verified against the real
30491
+ * grammars), distilled from each language's upstream `highlights.scm`.
30492
+ *
30493
+ * The TS query serves both `typescript` and `tsx` (tsx is a superset).
30494
+ */
30495
+ const TS_HIGHLIGHT_QUERY = `
30496
+ ; Comments
30497
+ (comment) @comment
30498
+
30499
+ ; Strings
30500
+ [
30501
+ (string)
30502
+ (template_string)
30503
+ ] @string
30504
+ (regex) @string
30505
+
30506
+ ; Numbers
30507
+ (number) @number
30508
+
30509
+ ; Types
30510
+ (type_identifier) @type
30511
+ (predefined_type) @type
30512
+
30513
+ ; Literals
30514
+ [
30515
+ (true)
30516
+ (false)
30517
+ (null)
30518
+ (undefined)
30519
+ ] @constant
30520
+ (this) @keyword
30521
+ (super) @keyword
30522
+
30523
+ ; Properties
30524
+ (property_identifier) @property
30525
+
30526
+ ; Function definitions and calls (field-name patterns — no predicates)
30527
+ (function_declaration
30528
+ name: (identifier) @function)
30529
+ (function_expression
30530
+ name: (identifier) @function)
30531
+ (method_definition
30532
+ name: (property_identifier) @function)
30533
+ (call_expression
30534
+ function: (identifier) @function)
30535
+ (call_expression
30536
+ function: (member_expression
30537
+ property: (property_identifier) @function))
30538
+ (variable_declarator
30539
+ name: (identifier) @function
30540
+ value: [(function_expression) (arrow_function)])
30541
+
30542
+ ; Keywords (anonymous tokens — all valid in the TS/TSX grammars)
30543
+ [
30544
+ "abstract"
30545
+ "declare"
30546
+ "enum"
30547
+ "implements"
30548
+ "interface"
30549
+ "keyof"
30550
+ "namespace"
30551
+ "private"
30552
+ "protected"
30553
+ "public"
30554
+ "type"
30555
+ "readonly"
30556
+ "override"
30557
+ "satisfies"
30558
+ "as"
30559
+ "async"
30560
+ "await"
30561
+ "break"
30562
+ "case"
30563
+ "catch"
30564
+ "class"
30565
+ "const"
30566
+ "continue"
30567
+ "debugger"
30568
+ "default"
30569
+ "delete"
30570
+ "do"
30571
+ "else"
30572
+ "export"
30573
+ "extends"
30574
+ "finally"
30575
+ "for"
30576
+ "from"
30577
+ "function"
30578
+ "get"
30579
+ "if"
30580
+ "import"
30581
+ "in"
30582
+ "instanceof"
30583
+ "let"
30584
+ "new"
30585
+ "of"
30586
+ "return"
30587
+ "set"
30588
+ "static"
30589
+ "switch"
30590
+ "throw"
30591
+ "try"
30592
+ "typeof"
30593
+ "var"
30594
+ "void"
30595
+ "while"
30596
+ "with"
30597
+ "yield"
30598
+ ] @keyword
30599
+ `.trim();
30600
+ /** Python (validated against tree-sitter-python 0.23.6). */
30601
+ const PYTHON_HIGHLIGHT_QUERY = `
30602
+ (comment) @comment
30603
+ (string) @string
30604
+ (integer) @number
30605
+ (float) @number
30606
+ (type) @type
30607
+ (function_definition
30608
+ name: (identifier) @function)
30609
+ (class_definition
30610
+ name: (identifier) @type)
30611
+ (call
30612
+ function: (identifier) @function)
30613
+ [ (true) (false) (none) ] @constant
30614
+ [
30615
+ "def" "class" "return" "pass" "if" "elif" "else" "for" "while"
30616
+ "import" "from" "as" "with" "try" "except" "finally" "raise"
30617
+ "lambda" "yield" "global" "nonlocal" "assert" "del" "in" "not"
30618
+ "and" "or" "is" "await" "async"
30619
+ ] @keyword
30620
+ `.trim();
30621
+ /** Rust (validated against tree-sitter-rust 0.24.0). */
30622
+ const RUST_HIGHLIGHT_QUERY = `
30623
+ [ (line_comment) (block_comment) ] @comment
30624
+ [ (string_literal) (char_literal) (raw_string_literal) ] @string
30625
+ (integer_literal) @number
30626
+ (float_literal) @number
30627
+ [ (primitive_type) (type_identifier) ] @type
30628
+ (function_item
30629
+ name: (identifier) @function)
30630
+ (call_expression
30631
+ function: (identifier) @function)
30632
+ (boolean_literal) @constant
30633
+ [
30634
+ "fn" "let" "const" "static" "if" "else" "match" "for" "while"
30635
+ "loop" "return" "break" "continue" "struct" "enum" "trait" "impl"
30636
+ "use" "mod" "pub" "as" "where" "in" "unsafe" "async" "await"
30637
+ "dyn" "type"
30638
+ ] @keyword
30639
+ `.trim();
30640
+ /** Go (validated against tree-sitter-go 0.25.0). */
30641
+ const GO_HIGHLIGHT_QUERY = `
30642
+ (comment) @comment
30643
+ [ (interpreted_string_literal) (raw_string_literal) (rune_literal) ] @string
30644
+ (int_literal) @number
30645
+ (float_literal) @number
30646
+ (type_identifier) @type
30647
+ (function_declaration
30648
+ name: (identifier) @function)
30649
+ (method_declaration
30650
+ name: (field_identifier) @function)
30651
+ (call_expression
30652
+ function: (identifier) @function)
30653
+ (call_expression
30654
+ function: (selector_expression
30655
+ field: (field_identifier) @function))
30656
+ [ (true) (false) (nil) (iota) ] @constant
30657
+ [
30658
+ "func" "var" "const" "type" "struct" "interface" "map" "chan"
30659
+ "package" "import" "return" "if" "else" "for" "range" "switch"
30660
+ "case" "default" "break" "continue" "go" "defer" "select"
30661
+ "fallthrough" "goto"
30662
+ ] @keyword
30663
+ `.trim();
30664
+ /** Highlight query keyed by tree-sitter language id. */
30665
+ const HIGHLIGHT_QUERIES = {
30666
+ typescript: TS_HIGHLIGHT_QUERY,
30667
+ tsx: TS_HIGHLIGHT_QUERY,
30668
+ python: PYTHON_HIGHLIGHT_QUERY,
30669
+ rust: RUST_HIGHLIGHT_QUERY,
30670
+ go: GO_HIGHLIGHT_QUERY,
30671
+ };
30672
+
30673
+ /**
30674
+ * Map a tree-sitter capture name to a normalized token type. Captures
30675
+ * are dotted (`a.b.c`); we key off the leading segment and fold the rest
30676
+ * in. Anything we don't have a color for collapses to `plain` (rendered
30677
+ * in the default foreground), so unmapped captures degrade gracefully.
30678
+ */
30679
+ function captureToToken(capture) {
30680
+ const base = capture.split('.')[0];
30681
+ switch (base) {
30682
+ case 'keyword':
30683
+ return 'keyword';
30684
+ case 'string':
30685
+ return 'string';
30686
+ case 'comment':
30687
+ return 'comment';
30688
+ case 'number':
30689
+ return 'number';
30690
+ case 'type':
30691
+ return 'type';
30692
+ case 'function':
30693
+ case 'method':
30694
+ case 'constructor':
30695
+ return 'function';
30696
+ case 'property':
30697
+ return 'property';
30698
+ case 'constant':
30699
+ return 'constant';
30700
+ default:
30701
+ return 'plain';
30702
+ }
30703
+ }
30704
+
30705
+ /**
30706
+ * Tree-sitter syntax highlighter for the diff view.
30707
+ *
30708
+ * Reuses the existing tree-sitter runtime (`getTreeSitterParser`) — same
30709
+ * lazy init, same bundled `typescript` / `tsx` grammars (offline-safe),
30710
+ * same "return undefined when .wasm is unavailable" contract. On top of
30711
+ * that we build one `Query` per language from our embedded highlight
30712
+ * query and tokenize code **per line**.
30713
+ *
30714
+ * Per-line (rather than whole-file) tokenization keeps this uniform
30715
+ * across every diff source — stash / compare / commit / worktree all
30716
+ * hand us diff lines, never reconstructed files — and tree-sitter's
30717
+ * error tolerance means a single statement still yields good captures.
30718
+ * Results are cached by (language, line) so re-renders and repeated
30719
+ * lines are free.
30720
+ *
30721
+ * Everything degrades to "no spans" (the caller renders the plain
30722
+ * single-color line): missing grammar, query compile failure, parse
30723
+ * error, non-ASCII text (byte/char offset skew), or an over-long line.
30724
+ */
30725
+ // Longest extension first so `.d.ts` / `.mts` win over `.ts`.
30726
+ //
30727
+ // NOTE: typescript + tsx grammars are bundled (offline). python / rust /
30728
+ // go grammars download on demand and are only available once cached
30729
+ // (prefetched) — `getTreeSitterParser` returns undefined otherwise, so
30730
+ // those diffs render plain until the grammar is present. Same
30731
+ // availability model the structural parsers already use.
30732
+ const EXT_LANGUAGE = [
30733
+ ['.tsx', 'tsx'],
30734
+ ['.jsx', 'tsx'],
30735
+ ['.mts', 'typescript'],
30736
+ ['.cts', 'typescript'],
30737
+ ['.mjs', 'tsx'],
30738
+ ['.cjs', 'tsx'],
30739
+ ['.ts', 'typescript'],
30740
+ ['.js', 'tsx'],
30741
+ ['.py', 'python'],
30742
+ ['.pyi', 'python'],
30743
+ ['.rs', 'rust'],
30744
+ ['.go', 'go'],
30745
+ ];
30746
+ /** Map a file path to a highlight language, or undefined when unsupported. */
30747
+ function detectSyntaxLanguage(filePath) {
30748
+ const lower = filePath.toLowerCase();
30749
+ for (const [ext, language] of EXT_LANGUAGE) {
30750
+ if (lower.endsWith(ext))
30751
+ return language;
30752
+ }
30753
+ return undefined;
30754
+ }
30755
+ // Printable ASCII (+ tab). Outside this range tree-sitter's byte offsets
30756
+ // diverge from JS string char offsets, which would misalign spans — so
30757
+ // we skip those lines entirely (rendered plain).
30758
+ const ASCII_ONLY = /^[\t\x20-\x7E]*$/;
30759
+ const MAX_LINE_LENGTH = 2000;
30760
+ // `null` marks "tried and failed" so we don't retry the grammar/query
30761
+ // load on every line.
30762
+ const queryCache = new Map();
30763
+ const spanCache = new Map();
30764
+ async function getQuery(language) {
30765
+ if (queryCache.has(language)) {
30766
+ return queryCache.get(language) ?? undefined;
30767
+ }
30768
+ const querySource = HIGHLIGHT_QUERIES[language];
30769
+ const loaded = querySource ? await getTreeSitterParser(language) : undefined;
30770
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30771
+ const grammar = loaded?.parser?.language;
30772
+ if (!loaded || !grammar || !querySource) {
30773
+ queryCache.set(language, null);
30774
+ return undefined;
30775
+ }
30776
+ try {
30777
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30778
+ const mod = (await import('web-tree-sitter'));
30779
+ const QueryCtor = mod.Query;
30780
+ const query = new QueryCtor(grammar, querySource);
30781
+ queryCache.set(language, query);
30782
+ return query;
30783
+ }
30784
+ catch {
30785
+ queryCache.set(language, null);
30786
+ return undefined;
30787
+ }
30788
+ }
30789
+ function buildSpans(text, captures) {
30790
+ const n = text.length;
30791
+ const charType = new Array(n);
30792
+ // Paint widest captures first so narrower/inner captures override them
30793
+ // — yields clean, non-overlapping spans without nesting bookkeeping.
30794
+ const ordered = [...captures].sort((a, b) => (b.node.endIndex - b.node.startIndex) - (a.node.endIndex - a.node.startIndex));
30795
+ for (const capture of ordered) {
30796
+ const token = captureToToken(capture.name);
30797
+ if (token === 'plain')
30798
+ continue;
30799
+ const start = Math.max(0, capture.node.startIndex);
30800
+ const end = Math.min(n, capture.node.endIndex);
30801
+ for (let i = start; i < end; i++)
30802
+ charType[i] = token;
30803
+ }
30804
+ // Coalesce runs of equal token type (gaps → 'plain').
30805
+ const spans = [];
30806
+ let i = 0;
30807
+ while (i < n) {
30808
+ const token = charType[i] ?? 'plain';
30809
+ let j = i + 1;
30810
+ while (j < n && (charType[j] ?? 'plain') === token)
30811
+ j++;
30812
+ spans.push({ start: i, end: j, token });
30813
+ i = j;
30814
+ }
30815
+ return spans;
30816
+ }
30817
+ /**
30818
+ * Tokenize a single line of code into non-overlapping spans covering the
30819
+ * whole string (plain runs included). Returns `[]` when the line can't
30820
+ * be highlighted (no grammar, parse error, non-ASCII, too long) so the
30821
+ * caller falls back to its plain single-color rendering.
30822
+ */
30823
+ async function highlightLine(language, text) {
30824
+ if (!text)
30825
+ return [];
30826
+ if (text.length > MAX_LINE_LENGTH || !ASCII_ONLY.test(text))
30827
+ return [];
30828
+ const key = `${language}${text}`;
30829
+ const cached = spanCache.get(key);
30830
+ if (cached)
30831
+ return cached;
30832
+ const query = await getQuery(language);
30833
+ const loaded = await getTreeSitterParser(language);
30834
+ if (!query || !loaded) {
30835
+ spanCache.set(key, []);
30836
+ return [];
30837
+ }
30838
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30839
+ let tree;
30840
+ try {
30841
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30842
+ tree = loaded.parser.parse(text);
30843
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30844
+ const captures = query.captures(tree.rootNode);
30845
+ const spans = buildSpans(text, captures);
30846
+ spanCache.set(key, spans);
30847
+ return spans;
30848
+ }
30849
+ catch {
30850
+ spanCache.set(key, []);
30851
+ return [];
30852
+ }
30853
+ finally {
30854
+ tree?.delete?.();
30855
+ }
30856
+ }
30857
+ /**
30858
+ * Tokenize the code lines of a unified diff. Strips the leading
30859
+ * `+` / `-` / ` ` marker before highlighting (headers and `@@` lines are
30860
+ * skipped — they're not code), and returns a map keyed by the
30861
+ * marker-stripped line text so the renderer can look spans up directly.
30862
+ * Lines that yield no spans are omitted (renderer falls back to plain).
30863
+ */
30864
+ /**
30865
+ * Pick the unique, marker-stripped code lines out of a unified diff:
30866
+ * only `+` / `-` / ` ` rows are code; `diff`/`index`/`@@`/`+++`/`---`
30867
+ * headers are skipped. Pure + exported so the selection is testable
30868
+ * without a grammar.
30869
+ */
30870
+ function selectDiffCodeLines(lines) {
30871
+ const seen = new Set();
30872
+ // Hunk-aware: a `+`/`-`/` ` line is code only INSIDE a hunk. This is
30873
+ // what distinguishes a real added line from the `+++ b/file` /
30874
+ // `--- a/file` file headers (which precede the first `@@` and also
30875
+ // start with `+`/`-`). Same stateful rule the split-diff parser uses.
30876
+ let inHunk = false;
30877
+ for (const line of lines) {
30878
+ if (!line)
30879
+ continue;
30880
+ if (line.startsWith('@@')) {
30881
+ inHunk = true;
30882
+ continue;
30883
+ }
30884
+ if (!inHunk)
30885
+ continue;
30886
+ const marker = line[0];
30887
+ if (marker !== '+' && marker !== '-' && marker !== ' ') {
30888
+ // A non-diff line (blank-separator label, next file's `diff --git`)
30889
+ // ends the current hunk until the next `@@`.
30890
+ inHunk = false;
30891
+ continue;
30892
+ }
30893
+ seen.add(line.slice(1));
30894
+ }
30895
+ return [...seen];
30896
+ }
30897
+ async function highlightDiffCode(filePath, lines) {
30898
+ const result = new Map();
30899
+ const language = detectSyntaxLanguage(filePath);
30900
+ if (!language)
30901
+ return result;
30902
+ for (const code of selectDiffCodeLines(lines)) {
30903
+ const spans = await highlightLine(language, code);
30904
+ if (spans.length)
30905
+ result.set(code, spans);
30906
+ }
30907
+ return result;
30908
+ }
30909
+
30209
30910
  async function runAction$4(action, successMessage) {
30210
30911
  try {
30211
30912
  await action();
@@ -33927,11 +34628,50 @@ function flushChangeBlock(removals, additions, rows) {
33927
34628
  removals.length = 0;
33928
34629
  additions.length = 0;
33929
34630
  }
33930
- function buildSplitDiffRows(unifiedLines) {
33931
- const rows = [];
34631
+ /**
34632
+ * Replay the hunk parser over `unifiedLines[0..upTo)` (exclusive) and
34633
+ * return the parse state at that boundary. Used by the split renderer
34634
+ * to seed `buildSplitDiffRows` with the correct in-hunk flag and
34635
+ * line-number cursors when it windows the diff to a scroll offset that
34636
+ * starts partway through a hunk. Counting mirrors `buildSplitDiffRows`
34637
+ * exactly so the seeded line numbers stay continuous across the cut.
34638
+ */
34639
+ function computeDiffContext(unifiedLines, upTo) {
33932
34640
  let oldLineNo = 0;
33933
34641
  let newLineNo = 0;
33934
34642
  let inHunk = false;
34643
+ const bound = Math.max(0, Math.min(upTo, unifiedLines.length));
34644
+ for (let i = 0; i < bound; i++) {
34645
+ const raw = unifiedLines[i];
34646
+ if (raw.startsWith('@@')) {
34647
+ const [oldStart, newStart] = parseHunkHeader(raw);
34648
+ oldLineNo = oldStart;
34649
+ newLineNo = newStart;
34650
+ inHunk = true;
34651
+ continue;
34652
+ }
34653
+ if (!inHunk || isDiffHeader(raw)) {
34654
+ continue;
34655
+ }
34656
+ if (raw.startsWith('-')) {
34657
+ oldLineNo += 1;
34658
+ continue;
34659
+ }
34660
+ if (raw.startsWith('+')) {
34661
+ newLineNo += 1;
34662
+ continue;
34663
+ }
34664
+ // Context line (or `\ No newline` marker) advances both cursors.
34665
+ oldLineNo += 1;
34666
+ newLineNo += 1;
34667
+ }
34668
+ return { inHunk, oldLineNo, newLineNo };
34669
+ }
34670
+ function buildSplitDiffRows(unifiedLines, seed) {
34671
+ const rows = [];
34672
+ let oldLineNo = seed?.oldLineNo ?? 0;
34673
+ let newLineNo = seed?.newLineNo ?? 0;
34674
+ let inHunk = seed?.inHunk ?? false;
33935
34675
  const removals = [];
33936
34676
  const additions = [];
33937
34677
  const flushHeader = (text) => {
@@ -33992,6 +34732,32 @@ function buildSplitDiffRows(unifiedLines) {
33992
34732
  return rows;
33993
34733
  }
33994
34734
 
34735
+ function resolveSyntaxColor(token, theme) {
34736
+ if (theme.noColor)
34737
+ return undefined;
34738
+ const c = theme.colors;
34739
+ switch (token) {
34740
+ case 'keyword':
34741
+ return c.syntaxKeyword ?? 'magenta';
34742
+ case 'string':
34743
+ return c.syntaxString ?? 'green';
34744
+ case 'comment':
34745
+ return c.syntaxComment ?? 'gray';
34746
+ case 'number':
34747
+ return c.syntaxNumber ?? 'yellow';
34748
+ case 'type':
34749
+ return c.syntaxType ?? 'cyan';
34750
+ case 'function':
34751
+ return c.syntaxFunction ?? 'blue';
34752
+ case 'constant':
34753
+ return c.syntaxConstant ?? 'yellow';
34754
+ case 'property':
34755
+ return c.syntaxProperty ?? undefined;
34756
+ default:
34757
+ return undefined;
34758
+ }
34759
+ }
34760
+
33995
34761
  /**
33996
34762
  * Split-diff rendering helpers (#785) — shared between the diff
33997
34763
  * surface and any future surface that wants side-by-side diff layout.
@@ -34063,30 +34829,107 @@ function formatSplitDiffCell(side, columnWidth) {
34063
34829
  return `${lineNo} ${truncateCells(text, textRoom)}`.padEnd(columnWidth);
34064
34830
  }
34065
34831
  /**
34066
- * Render the split-diff body as a list of two-column rows. The caller
34067
- * is responsible for slicing the unified-line array to the visible
34068
- * window — the helper just transforms that slice into Ink nodes.
34832
+ * Render one split-diff column as an Ink node — syntax-highlighted when
34833
+ * spans are available for the line, plain otherwise.
34834
+ *
34835
+ * Highlighted cells keep the 4-digit line-number gutter but color IT
34836
+ * with the add/remove cue (green/red, dim for context) so the code body
34837
+ * is free to carry its syntax colors — the split layout's position
34838
+ * (old | new) plus the colored gutter still tells you what changed.
34839
+ * Width is budgeted exactly like `formatSplitDiffCell` (gutter + 1 space
34840
+ * + truncated code) so columns never drift.
34841
+ */
34842
+ function renderSplitDiffCell(h, Text, side, columnWidth, theme, syntaxSpans, key) {
34843
+ const text = side.text.replace(/\n$/, '');
34844
+ const spans = side.kind === 'add' || side.kind === 'remove' || side.kind === 'context'
34845
+ ? syntaxSpans?.get(text)
34846
+ : undefined;
34847
+ if (!spans || spans.length === 0) {
34848
+ return h(Text, { key, ...splitDiffSideProps(side.kind, theme) }, formatSplitDiffCell(side, columnWidth));
34849
+ }
34850
+ const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
34851
+ const textRoom = Math.max(1, columnWidth - 5);
34852
+ const gutterColor = side.kind === 'add'
34853
+ ? theme.colors.gitAdded
34854
+ : side.kind === 'remove'
34855
+ ? theme.colors.gitDeleted
34856
+ : undefined;
34857
+ const children = [];
34858
+ let used = 0;
34859
+ for (const span of spans) {
34860
+ if (used >= textRoom)
34861
+ break;
34862
+ const segment = truncateCells(text.slice(span.start, span.end), textRoom - used);
34863
+ if (!segment)
34864
+ continue;
34865
+ used += cellWidth(segment);
34866
+ children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
34867
+ }
34868
+ return h(Text, { key }, h(Text, { key: `${key}-g`, color: gutterColor, dimColor: !gutterColor }, `${lineNo} `), ...children);
34869
+ }
34870
+ /**
34871
+ * Render the split-diff body as a list of two-column rows.
34872
+ *
34873
+ * Takes the FULL unified-line array plus the scroll offset + visible
34874
+ * row budget, and windows it internally. The windowing has to live
34875
+ * here (not the caller) because the parser is stateful: a window that
34876
+ * starts partway through a hunk needs the hunk context (in-hunk flag +
34877
+ * line-number cursors) that precedes it, or every visible line gets
34878
+ * misclassified as a header and painted in the accent color (#1114).
34879
+ * We compute that context from the lines before the window and seed
34880
+ * the parser with it.
34069
34881
  */
34070
- function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
34882
+ function renderSplitDiffBody(h, components, unifiedLines, startOffset, visibleRows, width, theme, keyPrefix, syntaxSpans) {
34071
34883
  const { Box, Text } = components;
34072
- const rows = buildSplitDiffRows(unifiedSlice);
34884
+ const seed = computeDiffContext(unifiedLines, startOffset);
34885
+ const unifiedSlice = unifiedLines.slice(startOffset, startOffset + visibleRows);
34886
+ const rows = buildSplitDiffRows(unifiedSlice, seed);
34073
34887
  // Reserve 3 columns of gutter (1 left padding from the Box + 1 column
34074
34888
  // separator + 1 right padding) so neither side touches the border.
34075
34889
  const usable = Math.max(20, width - 4);
34076
34890
  const gutter = 1;
34077
34891
  const half = Math.max(10, Math.floor((usable - gutter) / 2));
34078
34892
  return rows.map((row, index) => {
34079
- const leftProps = splitDiffSideProps(row.left.kind, theme);
34080
- const rightProps = splitDiffSideProps(row.right.kind, theme);
34081
- const leftText = formatSplitDiffCell(row.left, half);
34082
- const rightText = formatSplitDiffCell(row.right, half);
34893
+ const rowKey = `${keyPrefix}-${startOffset + index}`;
34083
34894
  return h(Box, {
34084
- key: `${keyPrefix}-${startOffset + index}`,
34895
+ key: rowKey,
34085
34896
  flexDirection: 'row',
34086
- }, h(Box, { width: half, flexShrink: 0 }, h(Text, leftProps, leftText)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, h(Text, rightProps, rightText)));
34897
+ }, h(Box, { width: half, flexShrink: 0 }, renderSplitDiffCell(h, Text, row.left, half, theme, syntaxSpans, `${rowKey}-l`)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, renderSplitDiffCell(h, Text, row.right, half, theme, syntaxSpans, `${rowKey}-r`)));
34087
34898
  });
34088
34899
  }
34089
34900
 
34901
+ /**
34902
+ * @param syntaxSpans map of marker-stripped code line → token spans
34903
+ * (from `highlightDiffCode`), or undefined when highlighting is off.
34904
+ * @param maxCells total cell budget for the whole line (marker + code).
34905
+ */
34906
+ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34907
+ const spans = line ? syntaxSpans?.get(line.slice(1)) : undefined;
34908
+ if (!spans || spans.length === 0) {
34909
+ return h(Text, { key, ...diffLineProps(line, theme) }, truncateCells(line, maxCells));
34910
+ }
34911
+ const marker = line[0];
34912
+ const markerColor = marker === '+'
34913
+ ? theme.colors.gitAdded
34914
+ : marker === '-'
34915
+ ? theme.colors.gitDeleted
34916
+ : undefined;
34917
+ const code = line.slice(1);
34918
+ const budget = Math.max(0, maxCells - 1); // reserve one cell for the marker
34919
+ const children = [];
34920
+ let used = 0;
34921
+ for (const span of spans) {
34922
+ if (used >= budget)
34923
+ break;
34924
+ const segment = truncateCells(code.slice(span.start, span.end), budget - used);
34925
+ if (!segment)
34926
+ continue;
34927
+ used += cellWidth(segment);
34928
+ children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
34929
+ }
34930
+ return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
34931
+ }
34932
+
34090
34933
  /**
34091
34934
  * Diff surface — the unified or side-by-side diff view. Four sources
34092
34935
  * route through here, disambiguated by `state.diffSource`:
@@ -34109,7 +34952,7 @@ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, th
34109
34952
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
34110
34953
  * of #890. No behavior change.
34111
34954
  */
34112
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
34955
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
34113
34956
  const { Box, Text } = components;
34114
34957
  const focused = state.focus === 'commits';
34115
34958
  const worktree = context.worktree;
@@ -34163,7 +35006,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34163
35006
  const stashBodyNodes = stashDiffLoading || !lines.length
34164
35007
  ? []
34165
35008
  : splitActive
34166
- ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
35009
+ ? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'stash-diff-split', syntaxSpans)
34167
35010
  : visibleLines.map((line, index) => {
34168
35011
  const absoluteIndex = state.diffPreviewOffset + index;
34169
35012
  const headerFile = stashFileByStartLine.get(absoluteIndex);
@@ -34200,10 +35043,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34200
35043
  : truncateCells(`${arrow}${headerFile.path}`, width - 4);
34201
35044
  })());
34202
35045
  }
34203
- return h(Text, {
34204
- key: `stash-diff-line-${absoluteIndex}`,
34205
- ...diffLineProps(line, theme),
34206
- }, truncateCells(line, width - 4));
35046
+ return renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `stash-diff-line-${absoluteIndex}`);
34207
35047
  });
34208
35048
  return h(Box, {
34209
35049
  borderColor: focusBorderColor(theme, focused),
@@ -34244,11 +35084,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34244
35084
  const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
34245
35085
  ? []
34246
35086
  : splitActive
34247
- ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
34248
- : visibleLines.map((line, index) => h(Text, {
34249
- key: `compare-diff-line-${state.diffPreviewOffset + index}`,
34250
- ...diffLineProps(line, theme),
34251
- }, truncateCells(line, width - 4)));
35087
+ ? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'compare-diff-split', syntaxSpans)
35088
+ : visibleLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `compare-diff-line-${state.diffPreviewOffset + index}`));
34252
35089
  return h(Box, {
34253
35090
  borderColor: focusBorderColor(theme, focused),
34254
35091
  borderStyle: theme.borderStyle,
@@ -34286,7 +35123,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34286
35123
  ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
34287
35124
  : previewHunks.length
34288
35125
  ? [
34289
- `Selected file: ${selectedDetailFile?.path || ''}`,
35126
+ // File path is already shown in the panel title bar (right) —
35127
+ // no redundant "Selected file:" line here.
34290
35128
  currentHunkLabel,
34291
35129
  `Lines ${Math.min(state.diffPreviewOffset + 1, previewHunks.length || 1)}-${Math.min(state.diffPreviewOffset + visiblePreviewHunks.length, previewHunks.length)}/${previewHunks.length}`,
34292
35130
  '',
@@ -34298,11 +35136,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34298
35136
  const commitBodyNodes = filePreviewLoading || !previewHunks.length
34299
35137
  ? []
34300
35138
  : splitActive
34301
- ? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
34302
- : visiblePreviewHunks.map((line, index) => h(Text, {
34303
- key: `diff-surface-line-${state.diffPreviewOffset + index}`,
34304
- ...diffLineProps(line, theme),
34305
- }, truncateCells(line, 140)));
35139
+ ? renderSplitDiffBody(h, components, previewHunks, state.diffPreviewOffset, visibleRows, width, theme, 'commit-diff-split', syntaxSpans)
35140
+ : visiblePreviewHunks.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.diffPreviewOffset + index}`));
34306
35141
  return h(Box, {
34307
35142
  borderColor: focusBorderColor(theme, focused),
34308
35143
  borderStyle: theme.borderStyle,
@@ -34324,7 +35159,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34324
35159
  ? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
34325
35160
  : worktreeFile
34326
35161
  ? [
34327
- `Selected file: ${worktreeFile.path}`,
35162
+ // File path is already shown in the panel title bar (right) —
35163
+ // no redundant "Selected file:" line here.
34328
35164
  worktreeHunksLoading
34329
35165
  ? 'Hunks loading...'
34330
35166
  : worktreeHunks?.hunks.length
@@ -34348,10 +35184,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34348
35184
  key: `diff-surface-header-${index}`,
34349
35185
  dimColor: index > 0,
34350
35186
  }, truncateCells(line, 140))), ...(showDiffLines
34351
- ? visibleDiffLines.map((line, index) => h(Text, {
34352
- key: `diff-surface-line-${state.worktreeDiffOffset + index}`,
34353
- ...diffLineProps(line, theme),
34354
- }, truncateCells(line, 140)))
35187
+ ? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
34355
35188
  : []));
34356
35189
  }
34357
35190
 
@@ -36026,6 +36859,37 @@ function renderThemePickerOverlay(h, components, filter, index, width, theme, fo
36026
36859
  ? [h(Text, { key: 'theme-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
36027
36860
  : []));
36028
36861
  }
36862
+ /**
36863
+ * "Add to .gitignore" quick-pick overlay (`i` on the status view).
36864
+ * Modeled on the theme picker but with a fixed, file-derived option list
36865
+ * (no fuzzy filter — the menu is short): pick exact / by-extension /
36866
+ * by-folder / by-name, or the `Custom pattern…` escape hatch which opens
36867
+ * a free-text prompt. ↑/↓ to move, Enter to choose, Esc to cancel.
36868
+ */
36869
+ function renderGitignorePickerOverlay(h, components, file, index, width, theme, focused) {
36870
+ const { Box, Text } = components;
36871
+ const options = deriveGitignoreOptions(file);
36872
+ const selectedIndex = Math.max(0, Math.min(index, options.length - 1));
36873
+ const hint = '↑/↓ select · enter add · esc cancel';
36874
+ const itemLines = options.map((option, offset) => {
36875
+ const isSelected = offset === selectedIndex;
36876
+ const cursor = isSelected ? '>' : ' ';
36877
+ const glyph = option.custom ? '✎ ' : '+ ';
36878
+ return h(Text, {
36879
+ key: `gitignore-opt-${offset}`,
36880
+ bold: isSelected,
36881
+ dimColor: !isSelected,
36882
+ color: isSelected && !theme.noColor ? theme.colors.accent : undefined,
36883
+ }, `${cursor} ${glyph}`, truncateCells(option.label, width - 8));
36884
+ });
36885
+ return h(Box, {
36886
+ borderColor: focusBorderColor(theme, focused),
36887
+ borderStyle: theme.borderStyle,
36888
+ flexDirection: 'column',
36889
+ width,
36890
+ paddingX: 1,
36891
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Add to .gitignore', focused)), h(Text, { dimColor: true }, `${options.length} options`)), h(Text, { color: theme.colors.accent }, truncateCells(file || '(no file)', width - 4)), h(Text, { dimColor: true }, truncateCells(hint, width - 4)), h(Text, undefined, ''), ...itemLines);
36892
+ }
36029
36893
  /**
36030
36894
  * Split-plan overlay (#907) — renders the proposed commit groups for
36031
36895
  * the user to review before applying. Three phases driven by
@@ -37370,7 +38234,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
37370
38234
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
37371
38235
  * of #890. No behavior change.
37372
38236
  */
37373
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled) {
38237
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
37374
38238
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
37375
38239
  // detail) when active, because the content — multiple commit groups
37376
38240
  // with file lists, rationale, hunks — needs the full center width
@@ -37385,7 +38249,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
37385
38249
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
37386
38250
  }
37387
38251
  if (state.activeView === 'diff') {
37388
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
38252
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
37389
38253
  }
37390
38254
  if (state.activeView === 'compose') {
37391
38255
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
@@ -38465,6 +39329,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
38465
39329
  if (state.showThemePicker) {
38466
39330
  return renderThemePickerOverlay(h, components, state.themePickerFilter, state.themePickerIndex, width, theme, focused);
38467
39331
  }
39332
+ if (state.gitignorePicker) {
39333
+ return renderGitignorePickerOverlay(h, components, state.gitignorePicker.file, state.gitignorePicker.index, width, theme, focused);
39334
+ }
38468
39335
  if (state.inputPrompt) {
38469
39336
  return renderInputPromptPanel(h, components, state, width, theme, focused);
38470
39337
  }
@@ -38542,6 +39409,67 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
38542
39409
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
38543
39410
  }
38544
39411
 
39412
+ /**
39413
+ * Resolve + scaffold the coco config files the workstation can open in
39414
+ * `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
39415
+ *
39416
+ * Two scopes:
39417
+ * - `global` → `$XDG_CONFIG_HOME/coco/config.json` (default
39418
+ * `~/.config/coco/config.json`) — applies to every repo.
39419
+ * - `project` → `.coco.json` (preferred) or the legacy
39420
+ * `.coco.config.json` at the repo root — applies to the
39421
+ * current repository only.
39422
+ *
39423
+ * When the chosen file doesn't exist yet we write a minimal templated
39424
+ * starter (just the `$schema` link + a sample `logTui.theme.preset`) so
39425
+ * the user lands in an editable, schema-aware file instead of an empty
39426
+ * buffer or an error.
39427
+ */
39428
+ /**
39429
+ * Minimal starter config written when scaffolding a missing file. Keeps
39430
+ * the `$schema` link (so editors offer completion/validation) and one
39431
+ * illustrative key showing where settings live — small enough to not
39432
+ * impose opinions, structured enough to be a useful starting point.
39433
+ */
39434
+ const STARTER_CONFIG = `${JSON.stringify({
39435
+ $schema: SCHEMA_PUBLIC_URL,
39436
+ logTui: { theme: { preset: 'default' } },
39437
+ }, null, 2)}\n`;
39438
+ /** `$XDG_CONFIG_HOME/coco/config.json` (default `~/.config/coco/config.json`). */
39439
+ function getGlobalConfigPath() {
39440
+ return getXdgConfigPath();
39441
+ }
39442
+ /**
39443
+ * The project config path for `repoRoot`: the first existing of
39444
+ * `.coco.json` / `.coco.config.json`, else `.coco.json` as the default
39445
+ * to create.
39446
+ */
39447
+ function getProjectConfigPath(repoRoot) {
39448
+ for (const name of ['.coco.json', '.coco.config.json']) {
39449
+ const candidate = path__namespace.join(repoRoot, name);
39450
+ if (fs__namespace.existsSync(candidate))
39451
+ return candidate;
39452
+ }
39453
+ return path__namespace.join(repoRoot, '.coco.json');
39454
+ }
39455
+ /** Resolve the config path for a scope. `project` needs the repo root. */
39456
+ function resolveConfigPath(scope, repoRoot) {
39457
+ return scope === 'global' ? getGlobalConfigPath() : getProjectConfigPath(repoRoot);
39458
+ }
39459
+ /**
39460
+ * Ensure `filePath` exists, scaffolding the starter template (and any
39461
+ * missing parent directories) when it doesn't. Returns whether it was
39462
+ * just created so the caller can surface a "Created …" message.
39463
+ */
39464
+ function ensureConfigFile(filePath) {
39465
+ if (fs__namespace.existsSync(filePath)) {
39466
+ return { created: false };
39467
+ }
39468
+ fs__namespace.mkdirSync(path__namespace.dirname(filePath), { recursive: true });
39469
+ fs__namespace.writeFileSync(filePath, STARTER_CONFIG);
39470
+ return { created: true };
39471
+ }
39472
+
38545
39473
  /**
38546
39474
  * `LogInkApp` — the workstation's root React component. Hosts all state
38547
39475
  * via `useState`/`useEffect`/`useMemo`/`useCallback` hooks; wires up the
@@ -38771,7 +39699,7 @@ function enrichFilterActionWithRectification(action, state, context) {
38771
39699
  }
38772
39700
  }
38773
39701
  function LogInkApp(deps) {
38774
- const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme: baseTheme, themeConfig } = deps;
39702
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, syntaxHighlightEnabled, theme: baseTheme, themeConfig } = deps;
38775
39703
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
38776
39704
  const h = React.createElement;
38777
39705
  // Theme picker (gC) — live preview + apply. `themePreviewPreset` follows
@@ -38942,6 +39870,11 @@ function LogInkApp(deps) {
38942
39870
  const [worktreeDiffLoading, setWorktreeDiffLoading] = React.useState(false);
38943
39871
  const [worktreeHunks, setWorktreeHunks] = React.useState(undefined);
38944
39872
  const [worktreeHunksLoading, setWorktreeHunksLoading] = React.useState(false);
39873
+ // Syntax-highlight spans for the diff currently in view (#1117
39874
+ // follow-up). Computed off the render path by the effect below;
39875
+ // keyed by marker-stripped code line so the diff renderer looks
39876
+ // spans up directly. `undefined` = no highlighting (renders plain).
39877
+ const [diffSyntaxSpans, setDiffSyntaxSpans] = React.useState(undefined);
38945
39878
  // Stash diff explorer (Enter on a stash row): the runtime fetches
38946
39879
  // `git stash show -p <ref>` lazily once the diff view becomes active
38947
39880
  // with diffSource='stash'. Lines are stored as a flat string[] —
@@ -39973,6 +40906,53 @@ function LogInkApp(deps) {
39973
40906
  selectedWorktreeFile?.worktreeStatus,
39974
40907
  state.activeView,
39975
40908
  ]);
40909
+ // Syntax-highlight the diff currently in view, off the render path
40910
+ // (#1117 follow-up). Mirrors the worktree-diff effect: detect the
40911
+ // active file + its diff lines (worktree or commit source), tokenize
40912
+ // via tree-sitter, and store the per-line spans for the renderer.
40913
+ // Stash / compare sources aren't highlighted yet (multi-file patch /
40914
+ // no single path). Gated on the config flag + a color terminal.
40915
+ React.useEffect(() => {
40916
+ if (!syntaxHighlightEnabled || theme.noColor || state.activeView !== 'diff') {
40917
+ setDiffSyntaxSpans(undefined);
40918
+ return;
40919
+ }
40920
+ let filePath;
40921
+ let lines;
40922
+ if (state.diffSource === 'commit') {
40923
+ filePath = selectedDetailFile?.path;
40924
+ lines = filePreview?.hunks;
40925
+ }
40926
+ else if (worktreeDiff && !worktreeDiff.untracked) {
40927
+ filePath = worktreeDiff.filePath;
40928
+ lines = worktreeDiff.lines;
40929
+ }
40930
+ if (!filePath || !lines || lines.length === 0) {
40931
+ setDiffSyntaxSpans(undefined);
40932
+ return;
40933
+ }
40934
+ let active = true;
40935
+ void highlightDiffCode(filePath, lines)
40936
+ .then((map) => {
40937
+ if (active)
40938
+ setDiffSyntaxSpans(map.size > 0 ? map : undefined);
40939
+ })
40940
+ .catch(() => {
40941
+ if (active)
40942
+ setDiffSyntaxSpans(undefined);
40943
+ });
40944
+ return () => {
40945
+ active = false;
40946
+ };
40947
+ }, [
40948
+ syntaxHighlightEnabled,
40949
+ theme.noColor,
40950
+ state.activeView,
40951
+ state.diffSource,
40952
+ selectedDetailFile?.path,
40953
+ filePreview,
40954
+ worktreeDiff,
40955
+ ]);
39976
40956
  const toggleSelectedFileStage = React.useCallback(async () => {
39977
40957
  if (!selectedWorktreeFile) {
39978
40958
  dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
@@ -40647,6 +41627,28 @@ function LogInkApp(deps) {
40647
41627
  // refresh so the file row reflects the new staged/unstaged state.
40648
41628
  void refreshWorktreeContext({ silent: true });
40649
41629
  }, [dispatch, refreshWorktreeContext, resumeRef]);
41630
+ // Open the global or project coco config in $EDITOR (gk / gK + their
41631
+ // command-palette entries). Scaffolds a templated starter when the file
41632
+ // doesn't exist yet so the user never lands in an empty buffer or hits
41633
+ // a "no such file" error.
41634
+ const openConfigInEditor = React.useCallback((scope) => {
41635
+ // `repoRootRef` is populated async from `git rev-parse --show-toplevel`;
41636
+ // fall back to cwd so a freshly-launched session can still scaffold +
41637
+ // open the project config before that resolves.
41638
+ const repoRoot = repoRootRef.current || process.cwd();
41639
+ const filePath = resolveConfigPath(scope, repoRoot);
41640
+ try {
41641
+ const { created } = ensureConfigFile(filePath);
41642
+ if (created) {
41643
+ dispatch({ type: 'setStatus', value: `Created ${scope} config at ${filePath}`, kind: 'success' });
41644
+ }
41645
+ }
41646
+ catch (error) {
41647
+ dispatch({ type: 'setStatus', value: `Could not create config: ${error.message}`, kind: 'error' });
41648
+ return;
41649
+ }
41650
+ openInEditor(filePath);
41651
+ }, [dispatch, openInEditor]);
40650
41652
  // `E` keystroke handler — open the current commit draft in $EDITOR
40651
41653
  // (or $VISUAL), then read the file back and update the compose state
40652
41654
  // with the saved content. Mirrors the suspend → spawn → resume
@@ -41502,6 +42504,7 @@ function LogInkApp(deps) {
41502
42504
  return { ok: false, message: 'No branch selected' };
41503
42505
  return pushBranch(git, branch);
41504
42506
  },
42507
+ 'add-to-gitignore': async () => addToGitignore(git, payload || ''),
41505
42508
  'rename-branch': async () => {
41506
42509
  const newName = payload?.trim();
41507
42510
  if (!newName)
@@ -41869,6 +42872,12 @@ function LogInkApp(deps) {
41869
42872
  dispatch({ type: 'pushView', value: 'history' });
41870
42873
  await refreshWorktreeContext();
41871
42874
  }
42875
+ // Refresh the worktree so a now-ignored untracked file drops out of
42876
+ // the status list immediately (the silent context refresh above
42877
+ // doesn't always re-read the worktree file set).
42878
+ if (result?.ok && id === 'add-to-gitignore') {
42879
+ await refreshWorktreeContext();
42880
+ }
41872
42881
  if (result?.ok && id === 'drop-stash') {
41873
42882
  // Explicit worktree refresh in case the dropped stash carried
41874
42883
  // untracked-file state that's now collected.
@@ -42638,9 +43647,22 @@ function LogInkApp(deps) {
42638
43647
  else if (event.type === 'openFileInEditor') {
42639
43648
  openInEditor(event.path);
42640
43649
  }
43650
+ else if (event.type === 'openConfigInEditor') {
43651
+ openConfigInEditor(event.scope);
43652
+ }
42641
43653
  else if (event.type === 'yankFromActiveView') {
42642
43654
  void yankFromActiveView(event.short);
42643
43655
  }
43656
+ else if (event.type === 'openGitignorePicker') {
43657
+ // Resolve the cursored worktree file here (the runtime owns the
43658
+ // selection→file mapping) and open the picker over its path.
43659
+ if (selectedWorktreeFile?.path) {
43660
+ dispatch({ type: 'openGitignorePicker', file: selectedWorktreeFile.path });
43661
+ }
43662
+ else {
43663
+ dispatch({ type: 'setStatus', value: 'No file under the cursor to ignore.', kind: 'warning' });
43664
+ }
43665
+ }
42644
43666
  else if (event.type === 'applyThemePreset') {
42645
43667
  // Apply for the session immediately, and best-effort persist to the
42646
43668
  // global config so it sticks across launches. The picker has already
@@ -42684,7 +43706,7 @@ function LogInkApp(deps) {
42684
43706
  if (showOnboarding) {
42685
43707
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
42686
43708
  }
42687
- 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));
43709
+ 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), diffSyntaxSpans), 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));
42688
43710
  }
42689
43711
 
42690
43712
  /**
@@ -42855,6 +43877,8 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
42855
43877
  // Resolve undefined → true so the default flips on automatically.
42856
43878
  // An explicit `false` from config opts out.
42857
43879
  dateBucketingEnabled: options.dateBucketing !== false,
43880
+ // Undefined → on; explicit `false` opts out.
43881
+ syntaxHighlightEnabled: options.syntaxHighlight !== false,
42858
43882
  ink,
42859
43883
  initialView: options.initialView || 'history',
42860
43884
  logArgv: options.logArgv,
@@ -43062,6 +44086,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
43062
44086
  appLabel: 'coco',
43063
44087
  idleTips: config.logTui?.idleTips,
43064
44088
  dateBucketing: config.logTui?.dateBucketing,
44089
+ syntaxHighlight: config.logTui?.syntaxHighlight,
43065
44090
  initialView: 'history',
43066
44091
  loadRows,
43067
44092
  logArgv,
@@ -43084,6 +44109,7 @@ async function startCocoUi(argv) {
43084
44109
  appLabel: 'coco',
43085
44110
  idleTips: config.logTui?.idleTips,
43086
44111
  dateBucketing: config.logTui?.dateBucketing,
44112
+ syntaxHighlight: config.logTui?.syntaxHighlight,
43087
44113
  initialView: argv.view || 'history',
43088
44114
  loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
43089
44115
  logArgv,