Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pierre-default-themes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Add Diffs.com's Pierre dark and light themes to Hunk's built-in theme selector and config ids.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ transparent_background = false
```

`theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer.
The bundled theme list also includes Diffs.com's Pierre defaults as `pierre-dark` and `pierre-light`.
Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases.
`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
`transparent_background` can also be written as `transparentBackground`.
Expand Down
37 changes: 20 additions & 17 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,24 +154,27 @@ describe("config resolution", () => {
});
});

test.each(["github-dark-default", "github-light-default", "dracula", "catppuccin-mocha"])(
"accepts custom theme base id: %s",
(base) => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
["[custom_theme]", `base = "${base}"`].join("\n"),
);

const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
});
test.each([
"github-dark-default",
"github-light-default",
"dracula",
"catppuccin-mocha",
"pierre-dark",
])("accepts custom theme base id: %s", (base) => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
["[custom_theme]", `base = "${base}"`].join("\n"),
);

expect(resolved.customTheme).toEqual({ base });
},
);
const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
});

expect(resolved.customTheme).toEqual({ base });
});

test("normalizes legacy custom theme base ids", () => {
const home = createTempDir("hunk-config-home-");
Expand Down
4 changes: 2 additions & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs";
import { join } from "node:path";
import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
import { BUNDLED_HIGHLIGHTER_THEME_IDS } from "../ui/lib/shikiThemes";
import { normalizeBuiltInThemeId } from "../ui/themes";
import { resolveGlobalConfigPath } from "./paths";
import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs";
Expand All @@ -14,7 +14,7 @@ import type {
VcsMode,
} from "./types";

const BUILT_IN_THEME_IDS = BUNDLED_SHIKI_THEME_IDS;
const BUILT_IN_THEME_IDS = BUNDLED_HIGHLIGHTER_THEME_IDS;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const CUSTOM_THEME_COLOR_KEYS = [
"background",
Expand Down
4 changes: 2 additions & 2 deletions src/opentui/themes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
import { BUNDLED_HIGHLIGHTER_THEME_IDS } from "../ui/lib/shikiThemes";

export const HUNK_DIFF_THEME_NAMES = BUNDLED_SHIKI_THEME_IDS;
export const HUNK_DIFF_THEME_NAMES = BUNDLED_HIGHLIGHTER_THEME_IDS;

export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number];
36 changes: 36 additions & 0 deletions src/ui/diff/pierre.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,4 +707,40 @@ describe("Pierre diff rows", () => {
"#eba0ac",
);
});

test("uses Pierre's bundled Diffs.com theme for Pierre syntax", async () => {
const metadata = parseDiffFromFile(
{ name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "pierre-before" },
{
name: "syntax.ts",
contents:
'const a = 1;\nexport class Greeter {\n count = 42;\n greet(user: User) {\n return "hello" + user.name;\n }\n}\n',
cacheKey: "pierre-after",
},
{ context: 3 },
true,
);
const file: DiffFile = {
id: "pierre-syntax",
path: "syntax.ts",
patch: "",
language: "typescript",
stats: { additions: 6, deletions: 0 },
metadata,
agent: null,
};
const theme = resolveTheme("pierre-dark", null);
const highlighted = await loadHighlightedDiff(file, theme);
const spans = buildStackRows(file, highlighted, theme)
.filter(
(row): row is Extract<DiffRow, { type: "stack-line" }> =>
row.type === "stack-line" && row.cell.kind === "addition",
)
.flatMap((row) => row.cell.spans);

expect(theme.syntaxTheme).toBe("pierre-dark");
expect(spans.find((span) => span.text.includes("export"))?.fg?.toLowerCase()).toBe("#ff678d");
expect(spans.find((span) => span.text.includes("Greeter"))?.fg?.toLowerCase()).toBe("#d568ea");
expect(spans.find((span) => span.text.includes('"hello"'))?.fg?.toLowerCase()).toBe("#5ecc71");
});
});
6 changes: 6 additions & 0 deletions src/ui/diff/pierre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
}

const normalized = color.trim().toLowerCase();
if (theme.syntaxTheme?.startsWith("pierre-")) {
// Pierre themes intentionally use these token colors; only remap them for other palettes.
cacheForTheme.set(color, normalized);
return normalized;
}

const reserved =
RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][
normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]
Expand Down
41 changes: 31 additions & 10 deletions src/ui/lib/shikiThemes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const BUNDLED_PIERRE_THEME_IDS = ["pierre-dark", "pierre-light"] as const;

export const BUNDLED_SHIKI_THEME_IDS = [
"andromeeda",
"aurora-x",
Expand Down Expand Up @@ -66,7 +68,18 @@ export const BUNDLED_SHIKI_THEME_IDS = [
"vitesse-light",
] as const;

export type BundledPierreThemeId = (typeof BUNDLED_PIERRE_THEME_IDS)[number];
export type BundledShikiThemeId = (typeof BUNDLED_SHIKI_THEME_IDS)[number];
export type BundledHighlighterThemeId = BundledPierreThemeId | BundledShikiThemeId;

// Keep Pierre's Diffs.com themes in the same alphabetical selector order as Shiki ids.
const PIERRE_THEME_INSERTION_INDEX = BUNDLED_SHIKI_THEME_IDS.indexOf("plastic");

export const BUNDLED_HIGHLIGHTER_THEME_IDS: readonly BundledHighlighterThemeId[] = [
...BUNDLED_SHIKI_THEME_IDS.slice(0, PIERRE_THEME_INSERTION_INDEX),
...BUNDLED_PIERRE_THEME_IDS,
...BUNDLED_SHIKI_THEME_IDS.slice(PIERRE_THEME_INSERTION_INDEX),
];
Comment on lines +76 to +82

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 Fragile insertion-index via indexOf

BUNDLED_SHIKI_THEME_IDS.indexOf("plastic") returns -1 if "plastic" is ever removed from the Shiki list. Array.prototype.slice treats negative indices as offsets from the end, so the resulting BUNDLED_HIGHLIGHTER_THEME_IDS would silently place Pierre themes immediately before "vitesse-light" rather than at the correct alphabetical position — breaking the documented "alphabetized with Shiki ids" contract without any error or failing test. A runtime assertion (if (PIERRE_THEME_INSERTION_INDEX === -1) throw new Error(...)) or at least a comment noting the assumption would make the failure loud instead of silent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/lib/shikiThemes.ts
Line: 76-82

Comment:
**Fragile insertion-index via `indexOf`**

`BUNDLED_SHIKI_THEME_IDS.indexOf("plastic")` returns `-1` if `"plastic"` is ever removed from the Shiki list. `Array.prototype.slice` treats negative indices as offsets from the end, so the resulting `BUNDLED_HIGHLIGHTER_THEME_IDS` would silently place Pierre themes immediately before `"vitesse-light"` rather than at the correct alphabetical position — breaking the documented "alphabetized with Shiki ids" contract without any error or failing test. A runtime assertion (`if (PIERRE_THEME_INSERTION_INDEX === -1) throw new Error(...)`) or at least a comment noting the assumption would make the failure loud instead of silent.

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


export const LEGACY_THEME_ID_ALIASES = {
graphite: "github-dark-default",
Expand All @@ -83,13 +96,17 @@ export function resolveLegacyThemeId(themeId: string | undefined) {
: undefined;
}

export interface BundledShikiThemeDiffColors {
export interface BundledHighlighterThemeDiffColors {
added?: string;
removed?: string;
modified?: string;
}

export const BUNDLED_SHIKI_THEME_BACKGROUNDS: Record<BundledShikiThemeId, string> = {
export type BundledShikiThemeDiffColors = BundledHighlighterThemeDiffColors;

export const BUNDLED_SHIKI_THEME_BACKGROUNDS: Record<BundledHighlighterThemeId, string> = {
"pierre-dark": "#0a0a0a",
"pierre-light": "#ffffff",
andromeeda: "#23262e",
"aurora-x": "#07090f",
"ayu-dark": "#10141c",
Expand Down Expand Up @@ -157,7 +174,9 @@ export const BUNDLED_SHIKI_THEME_BACKGROUNDS: Record<BundledShikiThemeId, string
"vitesse-light": "#ffffff",
};

export const BUNDLED_SHIKI_THEME_FOREGROUNDS: Partial<Record<BundledShikiThemeId, string>> = {
export const BUNDLED_SHIKI_THEME_FOREGROUNDS: Partial<Record<BundledHighlighterThemeId, string>> = {
"pierre-dark": "#fafafa",
"pierre-light": "#0a0a0a",
andromeeda: "#d5ced9",
"ayu-dark": "#bfbdb6",
"ayu-light": "#5c6166",
Expand Down Expand Up @@ -221,8 +240,10 @@ export const BUNDLED_SHIKI_THEME_FOREGROUNDS: Partial<Record<BundledShikiThemeId
};

export const BUNDLED_SHIKI_THEME_DIFF_COLORS: Partial<
Record<BundledShikiThemeId, BundledShikiThemeDiffColors>
Record<BundledHighlighterThemeId, BundledHighlighterThemeDiffColors>
> = {
"pierre-dark": { added: "#07c480", removed: "#ff2e3f", modified: "#009fff" },
"pierre-light": { added: "#18a46c", removed: "#d52c36", modified: "#009fff" },
andromeeda: { added: "#96e072", removed: "#ee5d43", modified: "#7cb7ff" },
"aurora-x": { added: "#63d188", removed: "#dd5074", modified: "#c778db" },
"ayu-dark": { added: "#70bf56", removed: "#f26d78", modified: "#73b8ff" },
Expand Down Expand Up @@ -284,23 +305,23 @@ export const BUNDLED_SHIKI_THEME_DIFF_COLORS: Partial<
"vitesse-light": { added: "#1e754f", removed: "#ab5959", modified: "#296aa3" },
};

/** Return the editor surface declared by a bundled Shiki theme, when Hunk knows it. */
/** Return the editor surface declared by a bundled Shiki/Pierre theme, when Hunk knows it. */
export function getBundledShikiThemeBackground(themeId: string | undefined) {
return themeId && themeId in BUNDLED_SHIKI_THEME_BACKGROUNDS
? BUNDLED_SHIKI_THEME_BACKGROUNDS[themeId as BundledShikiThemeId]
? BUNDLED_SHIKI_THEME_BACKGROUNDS[themeId as BundledHighlighterThemeId]
: undefined;
}

/** Return the editor foreground declared by a bundled Shiki theme, when Hunk knows it. */
/** Return the editor foreground declared by a bundled Shiki/Pierre theme, when Hunk knows it. */
export function getBundledShikiThemeForeground(themeId: string | undefined) {
return themeId && themeId in BUNDLED_SHIKI_THEME_FOREGROUNDS
? BUNDLED_SHIKI_THEME_FOREGROUNDS[themeId as BundledShikiThemeId]
? BUNDLED_SHIKI_THEME_FOREGROUNDS[themeId as BundledHighlighterThemeId]
: undefined;
}

/** Return semantic diff colors declared by a bundled Shiki theme, when Hunk knows them. */
/** Return semantic diff colors declared by a bundled Shiki/Pierre theme, when Hunk knows them. */
export function getBundledShikiThemeDiffColors(themeId: string | undefined) {
return themeId && themeId in BUNDLED_SHIKI_THEME_DIFF_COLORS
? BUNDLED_SHIKI_THEME_DIFF_COLORS[themeId as BundledShikiThemeId]
? BUNDLED_SHIKI_THEME_DIFF_COLORS[themeId as BundledHighlighterThemeId]
: undefined;
}
39 changes: 32 additions & 7 deletions src/ui/themes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { blendHex, contrastRatio, hexColorDistance } from "./lib/color";
import { BUNDLED_SHIKI_THEME_IDS } from "./lib/shikiThemes";
import { BUNDLED_HIGHLIGHTER_THEME_IDS } from "./lib/shikiThemes";
import {
availableThemeIds,
availableThemes,
Expand Down Expand Up @@ -58,10 +58,10 @@ describe("themes", () => {
});

test("exposes every bundled theme as a selectable theme", () => {
expect(availableThemeIds()).toEqual([...BUNDLED_SHIKI_THEME_IDS]);
expect(availableThemes().map((theme) => theme.id)).toEqual([...BUNDLED_SHIKI_THEME_IDS]);
expect(availableThemeIds()).toEqual([...BUNDLED_HIGHLIGHTER_THEME_IDS]);
expect(availableThemes().map((theme) => theme.id)).toEqual([...BUNDLED_HIGHLIGHTER_THEME_IDS]);

for (const themeId of BUNDLED_SHIKI_THEME_IDS) {
for (const themeId of BUNDLED_HIGHLIGHTER_THEME_IDS) {
const theme = resolveTheme(themeId, null);
expect(theme.id).toBe(themeId);
expect(theme.label).toBe(themeId);
Expand Down Expand Up @@ -89,8 +89,33 @@ describe("themes", () => {
expect(light.removedBg).toBe(blendHex("#cf222e", "#ffffff", 0.12));
});

test("exposes Pierre's Diffs.com default themes", () => {
const dark = resolveTheme("pierre-dark", null);
const light = resolveTheme("pierre-light", null);

expect(dark).toMatchObject({
id: "pierre-dark",
appearance: "dark",
background: "#0a0a0a",
syntaxTheme: "pierre-dark",
addedSignColor: "#07c480",
removedSignColor: "#ff2e3f",
});
expect(dark.syntaxColors.default).toBe("#fafafa");

expect(light).toMatchObject({
id: "pierre-light",
appearance: "light",
background: "#ffffff",
syntaxTheme: "pierre-light",
addedSignColor: "#18a46c",
removedSignColor: "#d52c36",
});
expect(light.syntaxColors.default).toBe("#0a0a0a");
});

test("contrast keeps every bundled theme diff row text and gutters readable", () => {
const failures = BUNDLED_SHIKI_THEME_IDS.flatMap((themeId) => {
const failures = BUNDLED_HIGHLIGHTER_THEME_IDS.flatMap((themeId) => {
const theme = resolveTheme(themeId, null);
return [
...themeContrastFailures([
Expand Down Expand Up @@ -147,7 +172,7 @@ describe("themes", () => {
});

test("contrast keeps fallback syntax colors readable on every bundled theme", () => {
const failures = BUNDLED_SHIKI_THEME_IDS.flatMap((themeId) => {
const failures = BUNDLED_HIGHLIGHTER_THEME_IDS.flatMap((themeId) => {
const theme = resolveTheme(themeId, null);
return themeContrastFailures(
SYNTAX_ROLES.flatMap((role) => [
Expand All @@ -174,7 +199,7 @@ describe("themes", () => {
});

test("contrast keeps every bundled theme chrome colors readable", () => {
const failures = BUNDLED_SHIKI_THEME_IDS.flatMap((themeId) => {
const failures = BUNDLED_HIGHLIGHTER_THEME_IDS.flatMap((themeId) => {
const theme = resolveTheme(themeId, null);
const sidebarForegrounds = [
["badgeAdded", theme.badgeAdded],
Expand Down
16 changes: 9 additions & 7 deletions src/ui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import type { ThemeMode } from "@opentui/core";
import type { CustomThemeConfig } from "../core/types";
import { blendHex, contrastRatio, relativeLuminance } from "./lib/color";
import {
BUNDLED_SHIKI_THEME_IDS,
BUNDLED_HIGHLIGHTER_THEME_IDS,
resolveLegacyThemeId,
getBundledShikiThemeBackground,
getBundledShikiThemeDiffColors,
getBundledShikiThemeForeground,
type BundledShikiThemeDiffColors,
type BundledShikiThemeId,
type BundledHighlighterThemeDiffColors,
type BundledHighlighterThemeId,
} from "./lib/shikiThemes";
import { withLazySyntaxStyle } from "./themes/syntax";
import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
Expand Down Expand Up @@ -116,8 +116,8 @@ function readableChromeColor(preferred: string, panel: string, panelAlt: string)
return anchor;
}

/** Derive one complete Hunk theme from one bundled Shiki editor theme. */
function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
/** Derive one complete Hunk theme from one bundled Shiki/Pierre editor theme. */
function buildShikiTheme(themeId: BundledHighlighterThemeId): AppTheme {
const editorBackground = getBundledShikiThemeBackground(themeId) ?? "#0d1117";
const editorForeground = getBundledShikiThemeForeground(themeId);
const diffColors = getBundledShikiThemeDiffColors(themeId);
Expand Down Expand Up @@ -234,7 +234,7 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
return withLazySyntaxStyle(themeBase, syntaxColors);
}

export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) =>
export const THEMES: AppTheme[] = BUNDLED_HIGHLIGHTER_THEME_IDS.map((themeId) =>
buildShikiTheme(themeId),
);

Expand Down Expand Up @@ -348,7 +348,9 @@ export function normalizeBuiltInThemeId(themeId: string) {
}

/** Return known semantic diff colors for a bundled Shiki-backed theme. */
export function bundledThemeDiffColors(themeId: string): BundledShikiThemeDiffColors | undefined {
export function bundledThemeDiffColors(
themeId: string,
): BundledHighlighterThemeDiffColors | undefined {
return getBundledShikiThemeDiffColors(themeId);
}

Expand Down