Skip to content

feat(theme): support raw Shiki syntax scopes#570

Merged
benvinegar merged 2 commits into
mainfrom
feat/raw-shiki-syntax-scopes
Jul 19, 2026
Merged

feat(theme): support raw Shiki syntax scopes#570
benvinegar merged 2 commits into
mainfrom
feat/raw-shiki-syntax-scopes

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Summary

  • add [custom_theme.syntax_scopes] for exact Shiki/TextMate selector-to-color overrides
  • derive content-addressed Shiki themes before tokenization instead of reverse-mapping Pierre output colors
  • preserve base-theme colors for scopes users do not override
  • temporarily translate deprecated [custom_theme.syntax] roles through an isolated compatibility adapter
  • include syntax-theme identity in diff and expanded-source highlight caches

Why

custom_theme.syntax.comment could not affect TypeScript comments because Hunk inferred semantic roles from a small set of Pierre output colors, and Pierre emitted comments using an unmapped color. Applying overrides as native Shiki rules preserves token identity and avoids maintaining another language-scope taxonomy in the main highlighting path.

Closes #557.

Configuration

[custom_theme.syntax_scopes]
"comment" = "#ffffff"
"punctuation.definition.comment" = "#ffffff"

The previous semantic table remains supported during a documented migration window and will be removed in the next major release. Exact scope entries take precedence over translated legacy entries with the same selector.

Validation

  • bun run typecheck
  • bun run lint
  • bun run format:check
  • focused config, theme, Pierre, expanded-source, static-pager, and compatibility tests: 74 passed
  • bun run test:tty-smoke: 9 passed
  • real tmux truecolor verification on dark and light themes, including the exact Custom syntax.comment override is ignored in TypeScript diffs #557 configuration
  • bun test: 1,222 passed, 14 skipped; the unrelated direct-file atomic-save PTY test remains flaky locally and is reproducible from main

Related work

PR #478 adds full VS Code/Shiki theme-file loading. Scope overlays and full theme files are complementary, but both touch theme registration and should be reconciled before merging independently.

This PR description was generated by Pi using OpenAI GPT-5.3

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces Hunk's indirect syntax-color approach (inferring semantic roles from Pierre output colors) with direct Shiki/TextMate scope overrides registered as content-addressed derived themes. A compatibility adapter (legacySyntaxScopes.ts) translates the deprecated [custom_theme.syntax] role table during a documented migration window, while the new [custom_theme.syntax_scopes] table feeds raw selectors straight to Shiki.

  • New syntaxHighlightTheme.ts builds and registers derived Shiki themes keyed by a SHA-256 fingerprint of the ordered scope overrides, ensuring different overrides get independent theme registrations and Pierre/Shiki caches.
  • Highlight cache keys updated in useHighlightedDiff and useHighlightedSource to include syntaxHighlightThemeName(theme), so scope-override changes correctly invalidate cached HAST results.
  • Legacy translation in legacySyntaxScopes.ts converts role keys to approximate TextMate selectors; exact scopes always override translated ones with the same selector via a simple object spread.

Confidence Score: 4/5

Safe to merge; the core highlighting path, cache invalidation, and legacy-compat adapter all work correctly.

The design is sound: content-addressed theme names keep highlight caches coherent, the legacy adapter is well-isolated, and the boundary normalization in config.ts prevents double-expansion in the happy path. All four comments are style and documentation concerns with no blocking correctness issues.

README.md drops the backward-compatibility note for the old [custom_theme.syntax] table. pierre.ts themeRenderCacheKey and themes.ts buildCustomTheme deserve inline comments explaining the cache-separation contract and the two-pass normalization design.

Important Files Changed

Filename Overview
src/ui/diff/syntaxHighlightTheme.ts New content-addressed Shiki theme builder; correctly fingerprints ordered scope overrides and uses a module-level Set for deduplication. User broad-selector overrides can be silently beaten by more-specific base-theme rules — documented behavior but not surfaced to users.
src/core/legacySyntaxScopes.ts New compatibility adapter that translates deprecated semantic [custom_theme.syntax] roles to approximate TextMate selectors; merge logic and test coverage look correct.
src/ui/diff/pierre.ts themeRenderCacheKey correctly omits the content-addressed syntax theme name because the WeakMap outer key on HAST node identity provides discrimination; however this is non-obvious and could cause subtle maintenance issues if the cache pattern is copied.
src/ui/themes.ts Wires syntaxScopeOverrides into buildCustomTheme; calls resolveSyntaxScopeOverrides defensively, which is a no-op for config-sourced themes since config.ts normalises at the boundary. The double-resolution is intentional but uncommented.
src/core/config.ts Adds readCustomSyntaxScopes and wires legacy + exact scopes through resolveSyntaxScopeOverrides at the parse boundary, correctly normalizing before storing in customTheme.syntaxScopes without ever setting the deprecated syntax field.
src/ui/diff/useHighlightedDiff.ts Correctly adds syntaxHighlightThemeName(theme) to the diff highlight cache key, ensuring scope-override changes invalidate cached highlights.
src/ui/diff/useHighlightedSource.ts Correctly adds syntaxHighlightThemeName(theme) to the source highlight cache key, matching the pattern in useHighlightedDiff.
src/core/types.ts Adds CustomSyntaxScopesConfig type and syntaxScopeOverrides to AppTheme; correctly marks CustomSyntaxColorsConfig and CustomThemeConfig.syntax as deprecated.
src/ui/themes/types.ts Adds syntaxScopeOverrides?: Record<string, string> to AppTheme; clean addition with no issues.
README.md Updates the config example to syntax_scopes but removes the old syntax table example entirely without noting backward compatibility — users on the old config have no migration path documented here.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["TOML config"] -->|"readCustomTheme"| B["resolveSyntaxScopeOverrides"]
    B --> C["CustomThemeConfig { syntaxScopes }"]
    C -->|"buildCustomTheme"| D["AppTheme { syntaxScopeOverrides }"]
    D -->|"syntaxHighlightThemeName"| E{"overrides?"}
    E -- yes --> F["hunk-custom-XXXX"]
    E -- no --> G["Base theme name"]
    F --> H["registerCustomTheme"]
    H --> I["Pierre / Shiki"]
    G --> I
    I --> J["HAST nodes"]
    J --> K["RenderSpan[]"] --> L["TUI / Pager"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["TOML config"] -->|"readCustomTheme"| B["resolveSyntaxScopeOverrides"]
    B --> C["CustomThemeConfig { syntaxScopes }"]
    C -->|"buildCustomTheme"| D["AppTheme { syntaxScopeOverrides }"]
    D -->|"syntaxHighlightThemeName"| E{"overrides?"}
    E -- yes --> F["hunk-custom-XXXX"]
    E -- no --> G["Base theme name"]
    F --> H["registerCustomTheme"]
    H --> I["Pierre / Shiki"]
    G --> I
    I --> J["HAST nodes"]
    J --> K["RenderSpan[]"] --> L["TUI / Pager"]
Loading

Comments Outside Diff (2)

  1. src/ui/diff/pierre.ts, line 44-71 (link)

    P2 themeRenderCacheKey omits the content-addressed syntax theme fingerprint

    themeRenderCacheKey includes theme.syntaxTheme (the base theme id, e.g. "github-dark-default") but not the content-addressed derived theme name produced by syntaxHighlightThemeName. Two custom themes that share the same base and the same UI palette colors but carry different syntaxScopeOverrides will produce identical inner cache keys inside flattenedHighlightedLineCache.

    Correctness is preserved today only because the WeakMap outer key is the HAST node object itself, and different Shiki theme registrations produce different node objects. A maintainer adding a new cache that keyed on themeRenderCacheKey alone (without the WeakMap indirection) would silently reuse stale spans across scope-override variants. Consider adding syntaxHighlightThemeName(theme) to the key array, or at minimum a comment that calls out why the WeakMap outer key makes the omission safe.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/ui/diff/pierre.ts
    Line: 44-71
    
    Comment:
    **`themeRenderCacheKey` omits the content-addressed syntax theme fingerprint**
    
    `themeRenderCacheKey` includes `theme.syntaxTheme` (the base theme id, e.g. `"github-dark-default"`) but not the content-addressed derived theme name produced by `syntaxHighlightThemeName`. Two custom themes that share the same base and the same UI palette colors but carry different `syntaxScopeOverrides` will produce identical inner cache keys inside `flattenedHighlightedLineCache`.
    
    Correctness is preserved today only because the WeakMap outer key is the HAST node object itself, and different Shiki theme registrations produce different node objects. A maintainer adding a new cache that keyed on `themeRenderCacheKey` alone (without the WeakMap indirection) would silently reuse stale spans across scope-override variants. Consider adding `syntaxHighlightThemeName(theme)` to the key array, or at minimum a comment that calls out why the WeakMap outer key makes the omission safe.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. README.md, line 155-173 (link)

    P2 README silently drops mention of the supported legacy [custom_theme.syntax] table

    The example previously showed [custom_theme.syntax]; the new text replaces it entirely with [custom_theme.syntax_scopes] without noting that the old table still works during the documented migration window. A user who upgrades and still has [custom_theme.syntax] in their config will see it silently continue to work, but if they consult the README to understand why it works or how to update it, there is no migration guidance there. The PR description and changeset describe the compatibility window; a brief note in the README (e.g. "The deprecated [custom_theme.syntax] role table is accepted during a migration window — see the changelog for details.") would make the upgrade story complete.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: README.md
    Line: 155-173
    
    Comment:
    **README silently drops mention of the supported legacy `[custom_theme.syntax]` table**
    
    The example previously showed `[custom_theme.syntax]`; the new text replaces it entirely with `[custom_theme.syntax_scopes]` without noting that the old table still works during the documented migration window. A user who upgrades and still has `[custom_theme.syntax]` in their config will see it silently continue to work, but if they consult the README to understand why it works or how to update it, there is no migration guidance there. The PR description and changeset describe the compatibility window; a brief note in the README (e.g. "The deprecated `[custom_theme.syntax]` role table is accepted during a migration window — see the changelog for details.") would make the upgrade story complete.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
src/ui/diff/pierre.ts:44-71
**`themeRenderCacheKey` omits the content-addressed syntax theme fingerprint**

`themeRenderCacheKey` includes `theme.syntaxTheme` (the base theme id, e.g. `"github-dark-default"`) but not the content-addressed derived theme name produced by `syntaxHighlightThemeName`. Two custom themes that share the same base and the same UI palette colors but carry different `syntaxScopeOverrides` will produce identical inner cache keys inside `flattenedHighlightedLineCache`.

Correctness is preserved today only because the WeakMap outer key is the HAST node object itself, and different Shiki theme registrations produce different node objects. A maintainer adding a new cache that keyed on `themeRenderCacheKey` alone (without the WeakMap indirection) would silently reuse stale spans across scope-override variants. Consider adding `syntaxHighlightThemeName(theme)` to the key array, or at minimum a comment that calls out why the WeakMap outer key makes the omission safe.

### Issue 2 of 4
src/ui/themes.ts:294-298
**Double-resolution of `resolveSyntaxScopeOverrides` silently no-ops for config-sourced themes**

`config.ts / readCustomTheme` already calls `resolveSyntaxScopeOverrides(legacySyntax, exactSyntaxScopes)` and stores the merged result in `customTheme.syntaxScopes`, deliberately never setting `customTheme.syntax`. When `buildCustomTheme` receives that object, the call here is `resolveSyntaxScopeOverrides(undefined, mergedScopes)`, which just returns `mergedScopes` unchanged.

The call is not wrong — it correctly handles programmatic callers that construct a `CustomThemeConfig` with both `syntax` and `syntaxScopes` set directly — but without a comment it looks like redundant work and may invite "cleanup" that removes the config-side normalization. A brief note explaining the two-pass contract (`config.ts` normalises at the parse boundary; `themes.ts` defends against direct callers) would help future maintainers.

### Issue 3 of 4
README.md:155-173
**README silently drops mention of the supported legacy `[custom_theme.syntax]` table**

The example previously showed `[custom_theme.syntax]`; the new text replaces it entirely with `[custom_theme.syntax_scopes]` without noting that the old table still works during the documented migration window. A user who upgrades and still has `[custom_theme.syntax]` in their config will see it silently continue to work, but if they consult the README to understand why it works or how to update it, there is no migration guidance there. The PR description and changeset describe the compatibility window; a brief note in the README (e.g. "The deprecated `[custom_theme.syntax]` role table is accepted during a migration window — see the changelog for details.") would make the upgrade story complete.

### Issue 4 of 4
src/ui/diff/syntaxHighlightTheme.ts:22-38
**User broad-selector overrides cannot beat more-specific base-theme rules**

`scopeSettings` is appended after `baseTheme.settings`. In Shiki's TextMate scoring, selector specificity is determined by the number of matching scope-path components, and position only breaks ties at equal specificity. A user who writes `"comment" = "#ffffff"` to recolor all comments will be silently overridden for any token where the base theme carries a more specific rule such as `comment.line.number-sign.python`.

This is technically correct TextMate behaviour, but the README currently says only "later rules win when matching selectors have equal specificity" — it doesn't warn that base-theme selectors with higher specificity will always win regardless of position. A note in the docs or in a config-validation warning would prevent confusing misconfiguration reports like the one this PR closes (#557).

Reviews (1): Last reviewed commit: "feat(theme): support raw Shiki syntax sc..." | Re-trigger Greptile

Comment thread src/ui/themes.ts
Comment on lines +294 to 298
// Keep the source-accurate base theme and pass exact TextMate selectors through unchanged.
// The diff highlighter registers that derived palette with Pierre by content hash.
syntaxTheme: baseTheme.syntaxTheme,
syntaxScopeOverrides: resolveSyntaxScopeOverrides(customTheme.syntax, customTheme.syntaxScopes),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double-resolution of resolveSyntaxScopeOverrides silently no-ops for config-sourced themes

config.ts / readCustomTheme already calls resolveSyntaxScopeOverrides(legacySyntax, exactSyntaxScopes) and stores the merged result in customTheme.syntaxScopes, deliberately never setting customTheme.syntax. When buildCustomTheme receives that object, the call here is resolveSyntaxScopeOverrides(undefined, mergedScopes), which just returns mergedScopes unchanged.

The call is not wrong — it correctly handles programmatic callers that construct a CustomThemeConfig with both syntax and syntaxScopes set directly — but without a comment it looks like redundant work and may invite "cleanup" that removes the config-side normalization. A brief note explaining the two-pass contract (config.ts normalises at the parse boundary; themes.ts defends against direct callers) would help future maintainers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/themes.ts
Line: 294-298

Comment:
**Double-resolution of `resolveSyntaxScopeOverrides` silently no-ops for config-sourced themes**

`config.ts / readCustomTheme` already calls `resolveSyntaxScopeOverrides(legacySyntax, exactSyntaxScopes)` and stores the merged result in `customTheme.syntaxScopes`, deliberately never setting `customTheme.syntax`. When `buildCustomTheme` receives that object, the call here is `resolveSyntaxScopeOverrides(undefined, mergedScopes)`, which just returns `mergedScopes` unchanged.

The call is not wrong — it correctly handles programmatic callers that construct a `CustomThemeConfig` with both `syntax` and `syntaxScopes` set directly — but without a comment it looks like redundant work and may invite "cleanup" that removes the config-side normalization. A brief note explaining the two-pass contract (`config.ts` normalises at the parse boundary; `themes.ts` defends against direct callers) would help future maintainers.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$body

This comment was generated by Pi using GPT-5.6 Sol

Comment on lines +22 to +38
async function buildCustomSyntaxTheme(
name: string,
baseThemeName: string,
scopeOverrides: Record<string, string>,
) {
const baseTheme = await resolvePierreTheme(baseThemeName);
const scopeSettings = Object.entries(scopeOverrides).map(([scope, foreground]) => ({
scope,
settings: { foreground },
}));

return {
...baseTheme,
name,
settings: [...(baseTheme.settings ?? []), ...scopeSettings],
} satisfies ThemeRegistrationResolved;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 User broad-selector overrides cannot beat more-specific base-theme rules

scopeSettings is appended after baseTheme.settings. In Shiki's TextMate scoring, selector specificity is determined by the number of matching scope-path components, and position only breaks ties at equal specificity. A user who writes "comment" = "#ffffff" to recolor all comments will be silently overridden for any token where the base theme carries a more specific rule such as comment.line.number-sign.python.

This is technically correct TextMate behaviour, but the README currently says only "later rules win when matching selectors have equal specificity" — it doesn't warn that base-theme selectors with higher specificity will always win regardless of position. A note in the docs or in a config-validation warning would prevent confusing misconfiguration reports like the one this PR closes (#557).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/diff/syntaxHighlightTheme.ts
Line: 22-38

Comment:
**User broad-selector overrides cannot beat more-specific base-theme rules**

`scopeSettings` is appended after `baseTheme.settings`. In Shiki's TextMate scoring, selector specificity is determined by the number of matching scope-path components, and position only breaks ties at equal specificity. A user who writes `"comment" = "#ffffff"` to recolor all comments will be silently overridden for any token where the base theme carries a more specific rule such as `comment.line.number-sign.python`.

This is technically correct TextMate behaviour, but the README currently says only "later rules win when matching selectors have equal specificity" — it doesn't warn that base-theme selectors with higher specificity will always win regardless of position. A note in the docs or in a config-validation warning would prevent confusing misconfiguration reports like the one this PR closes (#557).

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented this specificity rule in e3f0d4f. The README now states that a more-specific base selector beats a broader override and directs users to add the grammar-specific selector when needed.

Responded by Pi using OpenAI GPT-5.3.

This comment was generated by Pi using OpenAI GPT-5.3

@benvinegar

Copy link
Copy Markdown
Member Author

Addressed the Greptile review in e3f0d4f:

  1. Removed the generic themeRenderCacheKey. The word-diff cache now keys only its actual color dependencies, while the flattened-span cache documents that syntax identity comes from the highlighted HAST node used as its WeakMap key.
  2. Documented why themes.ts repeats compatibility normalization for direct programmatic callers after TOML normalization at the config boundary.
  3. The README already documented the legacy [custom_theme.syntax] migration window in the reviewed commit; that finding was stale. The guidance remains and now also explains coexistence and precedence.
  4. Documented that a more-specific base-theme selector beats a broader override and that users should add the grammar-specific selector in that case.

Focused theme/highlighting tests, typecheck, lint, and formatting all pass.

Responded by Pi using OpenAI GPT-5.3.

This comment was generated by Pi using OpenAI GPT-5.3

@benvinegar
benvinegar merged commit 94d61e1 into main Jul 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom syntax.comment override is ignored in TypeScript diffs

1 participant