diff --git a/CHANGELOG.md b/CHANGELOG.md index e23d8f03..2c3b1671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- Added a `--transparent-background` flag and `transparent_background` config option for translucent terminal setups. + ### Changed ### Fixed diff --git a/README.md b/README.md index b363e456..0d33ed7f 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,11 @@ exclude_untracked = false line_numbers = true wrap_lines = false agent_notes = false +transparent_background = false ``` `exclude_untracked` affects Git working-tree `hunk diff` sessions only. +`transparent_background` can also be written as `transparentBackground`. ### Git integration diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 402f1ac9..29eadd56 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -83,6 +83,7 @@ describe("parseCli", () => { "--wrap", "--no-hunk-headers", "--agent-notes", + "--transparent-background", "--watch", ]); @@ -99,6 +100,25 @@ describe("parseCli", () => { wrapLines: true, hunkHeaders: false, agentNotes: true, + transparentBackground: true, + }, + }); + }); + + test("parses transparent background toggles", async () => { + const transparent = await parseCli(["bun", "hunk", "diff", "--transparent-background"]); + const opaque = await parseCli(["bun", "hunk", "diff", "--no-transparent-background"]); + + expect(transparent).toMatchObject({ + kind: "vcs", + options: { + transparentBackground: true, + }, + }); + expect(opaque).toMatchObject({ + kind: "vcs", + options: { + transparentBackground: false, }, }); }); diff --git a/src/core/cli.ts b/src/core/cli.ts index 6844e29c..1452a718 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -59,6 +59,7 @@ function buildCommonOptions( agentContext?: string; pager?: boolean; watch?: boolean; + transparentBackground?: boolean; }, argv: string[], ): CommonOptions { @@ -73,6 +74,11 @@ function buildCommonOptions( wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"), + transparentBackground: resolveBooleanFlag( + argv, + "--transparent-background", + "--no-transparent-background", + ), }; } @@ -90,7 +96,9 @@ function applyCommonOptions(command: Command) { .option("--hunk-headers", "show hunk metadata rows") .option("--no-hunk-headers", "hide hunk metadata rows") .option("--agent-notes", "show agent notes by default") - .option("--no-agent-notes", "hide agent notes by default"); + .option("--no-agent-notes", "hide agent notes by default") + .option("--transparent-background", "let terminal background show through Hunk surfaces") + .option("--no-transparent-background", "paint Hunk surfaces with the active theme"); } /** Attach auto-refresh support to review commands that can reopen their source input. */ @@ -152,6 +160,8 @@ function renderCliHelp() { " --wrap / --no-wrap wrap or truncate long diff lines", " --hunk-headers / --no-hunk-headers show or hide hunk metadata rows", " --agent-notes / --no-agent-notes show or hide agent notes by default", + " --transparent-background / --no-transparent-background", + " let terminal background show through Hunk surfaces", " --theme named theme override", "", "Git diff options:", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index b409d828..7ba771a3 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -58,6 +58,7 @@ describe("config resolution", () => { [ 'theme = "graphite"', "line_numbers = false", + "transparentBackground = true", "", "[patch]", 'mode = "split"', @@ -87,9 +88,37 @@ describe("config resolution", () => { wrapLines: true, hunkHeaders: false, agentNotes: true, + transparentBackground: true, }); }); + test("accepts transparent background config and CLI overrides", () => { + const home = createTempDir("hunk-config-home-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "transparent_background = true\n"); + + const cwd = createTempDir("hunk-config-cwd-"); + const configured = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: {}, + }, + { cwd, env: { HOME: home } }, + ); + const overridden = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: { transparentBackground: false }, + }, + { cwd, env: { HOME: home } }, + ); + + expect(configured.input.options.transparentBackground).toBe(true); + expect(overridden.input.options.transparentBackground).toBe(false); + }); + test("defaults unspecified themes to graphite, including piped pager-style patch input", () => { const home = createTempDir("hunk-config-home-"); const cwd = createTempDir("hunk-config-cwd-"); diff --git a/src/core/config.ts b/src/core/config.ts index 67aa0322..f3f2cfb6 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -65,6 +65,9 @@ function readConfigPreferences(source: Record): CommonOptions { wrapLines: normalizeBoolean(source.wrap_lines), hunkHeaders: normalizeBoolean(source.hunk_headers), agentNotes: normalizeBoolean(source.agent_notes), + transparentBackground: + normalizeBoolean(source.transparentBackground) ?? + normalizeBoolean(source.transparent_background), }; } @@ -83,6 +86,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti wrapLines: overrides.wrapLines ?? base.wrapLines, hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, agentNotes: overrides.agentNotes ?? base.agentNotes, + transparentBackground: overrides.transparentBackground ?? base.transparentBackground, }; } @@ -145,6 +149,7 @@ export function resolveConfiguredCliInput( wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders, agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes, + transparentBackground: false, }; if (userConfigPath) { @@ -174,6 +179,7 @@ export function resolveConfiguredCliInput( wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + transparentBackground: resolvedOptions.transparentBackground ?? false, }; return { diff --git a/src/core/types.ts b/src/core/types.ts index c36934cd..2ba23acf 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -79,6 +79,7 @@ export interface CommonOptions { wrapLines?: boolean; hunkHeaders?: boolean; agentNotes?: boolean; + transparentBackground?: boolean; } export interface PersistedViewPreferences { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1bc36ce0..5c53853c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -28,7 +28,7 @@ import { fileRowId } from "./lib/ids"; import { openSelectedFileInEditor } from "./lib/openInEditor"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; -import { resolveTheme, THEMES } from "./themes"; +import { resolveTheme, THEMES, withTransparentBackground } from "./themes"; type FocusArea = "files" | "filter" | "note"; type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string }; @@ -128,7 +128,14 @@ export function App({ const [sessionNoticeText, setSessionNoticeText] = useState(null); const sessionNoticeTimeoutRef = useRef | null>(null); - const activeTheme = resolveTheme(themeId, detectedThemeMode ?? null); + const baseTheme = resolveTheme(themeId, detectedThemeMode ?? null); + const activeTheme = useMemo( + () => + bootstrap.input.options.transparentBackground + ? withTransparentBackground(baseTheme) + : baseTheme, + [baseTheme, bootstrap.input.options.transparentBackground], + ); const review = useReviewController({ files: bootstrap.changeset.files }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index 7d99b3d2..48881a5a 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -195,7 +195,16 @@ function strengthenWordDiffBg(lineBg: string, signColor: string) { /** Resolve the inline word-diff background, strengthening theme colors that are too subtle to see. */ function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) { - let cached = wordDiffBackgroundCache.get(theme.id); + const cacheKey = [ + theme.id, + theme.addedBg, + theme.addedContentBg, + theme.removedBg, + theme.removedContentBg, + theme.contextContentBg, + theme.panelAlt, + ].join(":"); + let cached = wordDiffBackgroundCache.get(cacheKey); if (!cached) { const addition = hexColorDistance(theme.addedContentBg, theme.addedBg) >= MIN_WORD_DIFF_BG_DISTANCE @@ -212,7 +221,7 @@ function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) { deletion, empty: theme.panelAlt, }; - wordDiffBackgroundCache.set(theme.id, cached); + wordDiffBackgroundCache.set(cacheKey, cached); } return cached[kind]; diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts new file mode 100644 index 00000000..f771ac73 --- /dev/null +++ b/src/ui/themes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTheme, TRANSPARENT_BACKGROUND, withTransparentBackground } from "./themes"; + +describe("themes", () => { + test("withTransparentBackground only swaps painted background fields", () => { + const theme = resolveTheme("graphite", null); + const transparent = withTransparentBackground(theme); + + expect(transparent).toMatchObject({ + background: TRANSPARENT_BACKGROUND, + panel: TRANSPARENT_BACKGROUND, + panelAlt: TRANSPARENT_BACKGROUND, + addedBg: TRANSPARENT_BACKGROUND, + removedBg: TRANSPARENT_BACKGROUND, + contextBg: TRANSPARENT_BACKGROUND, + addedContentBg: TRANSPARENT_BACKGROUND, + removedContentBg: TRANSPARENT_BACKGROUND, + contextContentBg: TRANSPARENT_BACKGROUND, + lineNumberBg: TRANSPARENT_BACKGROUND, + selectedHunk: TRANSPARENT_BACKGROUND, + noteBackground: TRANSPARENT_BACKGROUND, + noteTitleBackground: TRANSPARENT_BACKGROUND, + }); + expect(transparent.id).toBe(theme.id); + expect(transparent.label).toBe(theme.label); + expect(transparent.text).toBe(theme.text); + expect(transparent.muted).toBe(theme.muted); + expect(transparent.addedSignColor).toBe(theme.addedSignColor); + expect(transparent.removedSignColor).toBe(theme.removedSignColor); + expect(transparent.syntaxColors).toBe(theme.syntaxColors); + expect(theme.background).not.toBe(TRANSPARENT_BACKGROUND); + }); +}); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 38ad3883..10992ec4 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -1,5 +1,7 @@ import { RGBA, SyntaxStyle, type ThemeMode } from "@opentui/core"; +export const TRANSPARENT_BACKGROUND = "transparent"; + export interface AppTheme { id: string; label: string; @@ -300,3 +302,23 @@ export function resolveTheme(requested: string | undefined, themeMode: ThemeMode return THEMES.find((theme) => theme.id === "graphite") ?? THEMES[0]!; } + +/** Return a copy of a theme whose painted surfaces allow the terminal background through. */ +export function withTransparentBackground(theme: AppTheme): AppTheme { + return { + ...theme, + background: TRANSPARENT_BACKGROUND, + panel: TRANSPARENT_BACKGROUND, + panelAlt: TRANSPARENT_BACKGROUND, + addedBg: TRANSPARENT_BACKGROUND, + removedBg: TRANSPARENT_BACKGROUND, + contextBg: TRANSPARENT_BACKGROUND, + addedContentBg: TRANSPARENT_BACKGROUND, + removedContentBg: TRANSPARENT_BACKGROUND, + contextContentBg: TRANSPARENT_BACKGROUND, + lineNumberBg: TRANSPARENT_BACKGROUND, + selectedHunk: TRANSPARENT_BACKGROUND, + noteBackground: TRANSPARENT_BACKGROUND, + noteTitleBackground: TRANSPARENT_BACKGROUND, + }; +}