From bd112fa88884aff9ac0c48af78542342b6f46ce7 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 16 Apr 2026 07:35:41 +0000 Subject: [PATCH 1/6] fix: improve light terminal detection and text contrast - Add COLORFGBG env var fallback when OSC 11 detection times out - Use dark foreground (#1a1a1a) instead of palette[7] (#c0c0c0) for light mode system theme - Use backgroundElement for inline code blocks to ensure contrast on transparent backgrounds Closes #704 --- packages/opencode/src/cli/cmd/tui/app.tsx | 13 +++++++++++++ packages/opencode/src/cli/cmd/tui/context/theme.tsx | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index cc8231bd08..2f33bd4809 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -165,7 +165,20 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { timeout = setTimeout(() => { cleanup() + // altimate_change start — fix: use COLORFGBG env var as fallback before defaulting to dark + // Many terminals (iTerm2, xterm, etc.) set COLORFGBG=; to indicate color scheme. + // When bg component >= 8, the terminal likely has a light background. + const colorfgbg = process.env.COLORFGBG + if (colorfgbg) { + const parts = colorfgbg.split(";") + const bg = parseInt(parts[parts.length - 1]) + if (!isNaN(bg) && bg >= 8) { + resolve("light") + return + } + } resolve("dark") + // altimate_change end }, 1000) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 28493ed68b..46cf5d8726 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -430,10 +430,15 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) - const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" + // altimate_change start — fix: use contrast-appropriate fallback for light terminals + // palette[7] is #c0c0c0 (light gray) which is invisible on white backgrounds + const fgFallback = isDark ? colors.palette[7]! : "#1a1a1a" + const fg = RGBA.fromHex(colors.defaultForeground ?? fgFallback) + // altimate_change end + const col = (i: number) => { const value = colors.palette[i] if (value) return RGBA.fromHex(value) @@ -948,7 +953,10 @@ function getSyntaxRules(theme: Theme) { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, - background: theme.background, // inline code blends with page background + // altimate_change start — fix: use backgroundElement for inline code contrast on light themes + // theme.background can be transparent, leaving inline code with no contrast protection + background: theme.backgroundElement, + // altimate_change end }, }, { From d3a4dac0b6d4a4b409285ff44330fb0eb25c3ce0 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 16 Apr 2026 07:38:47 +0000 Subject: [PATCH 2/6] test: add tests for #704 light terminal fixes - Test markup.raw.inline uses backgroundElement (non-transparent) - Test system theme light-mode foreground fallback contrast - Test COLORFGBG env var parsing logic - Update test helper to match production inline code background change --- .../test/cli/tui/theme-light-mode.test.ts | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/cli/tui/theme-light-mode.test.ts b/packages/opencode/test/cli/tui/theme-light-mode.test.ts index 05c1b26a43..125f67f5c1 100644 --- a/packages/opencode/test/cli/tui/theme-light-mode.test.ts +++ b/packages/opencode/test/cli/tui/theme-light-mode.test.ts @@ -139,7 +139,7 @@ function getSyntaxRules(theme: Theme): SyntaxRule[] { }, { scope: ["markup.raw.inline"], - style: { foreground: theme.markdownCode, background: theme.background }, + style: { foreground: theme.markdownCode, background: theme.backgroundElement }, }, { scope: ["markup.link"], style: { foreground: theme.markdownLink, underline: true } }, { scope: ["spell", "nospell"], style: { foreground: theme.text } }, @@ -353,3 +353,84 @@ describe("dark theme: regression check", () => { }, ) }) + +// ─── Tests for #704 fixes ────────────────────────────────────────────────── + +describe("light theme: markup.raw.inline uses backgroundElement (issue #704)", () => { + test.each(LIGHT_THEMES)( + "%s: markup.raw.inline has non-transparent background", + (_name, themeJson) => { + const resolved = resolveTheme(themeJson, "light") + const rules = getSyntaxRules(resolved) + + const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline")) + expect(inlineRule).toBeDefined() + expect(inlineRule!.style.background).toBeDefined() + // backgroundElement should never be fully transparent + expect(inlineRule!.style.background!.a).toBeGreaterThan(0) + }, + ) + + test.each(LIGHT_THEMES)( + "%s: markup.raw.inline foreground is readable on its background", + (_name, themeJson) => { + const resolved = resolveTheme(themeJson, "light") + const rules = getSyntaxRules(resolved) + + const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline"))! + const fg = inlineRule.style.foreground! + const bg = inlineRule.style.background! + + const ratio = contrastRatio(fg, bg) + expect(ratio).toBeGreaterThanOrEqual(2) + }, + ) +}) + +describe("system theme: light mode foreground fallback (issue #704)", () => { + // Simulate what generateSystem does when defaultForeground is missing + // and mode is "light" — the fallback should NOT use palette[7] (#c0c0c0) + test("light mode fallback foreground is dark, not #c0c0c0", () => { + const PALETTE_7 = RGBA.fromHex("#c0c0c0") + const LIGHT_FALLBACK = RGBA.fromHex("#1a1a1a") + const WHITE_BG = RGBA.fromHex("#ffffff") + + // In light mode, we should use #1a1a1a, not #c0c0c0 + const ratio = contrastRatio(LIGHT_FALLBACK, WHITE_BG) + expect(ratio).toBeGreaterThanOrEqual(3) + + // palette[7] would be nearly invisible + const badRatio = contrastRatio(PALETTE_7, WHITE_BG) + expect(badRatio).toBeLessThan(2) // confirms the bug + }) +}) + +describe("COLORFGBG detection (issue #704)", () => { + // Test the parsing logic used in getTerminalBackgroundColor fallback + function parseCOLORFGBG(value: string): "dark" | "light" | null { + const parts = value.split(";") + const bg = parseInt(parts[parts.length - 1]) + if (isNaN(bg)) return null + return bg >= 8 ? "light" : "dark" + } + + test("COLORFGBG=0;15 -> light (white bg)", () => { + expect(parseCOLORFGBG("0;15")).toBe("light") + }) + + test("COLORFGBG=15;0 -> dark (black bg)", () => { + expect(parseCOLORFGBG("15;0")).toBe("dark") + }) + + test("COLORFGBG=0;7;15 -> light (3-part, last is bg)", () => { + expect(parseCOLORFGBG("0;7;15")).toBe("light") + }) + + test("COLORFGBG=15;0;0 -> dark (3-part, last is bg)", () => { + expect(parseCOLORFGBG("15;0;0")).toBe("dark") + }) + + test("invalid COLORFGBG -> null", () => { + expect(parseCOLORFGBG("abc")).toBe(null) + }) +}) From d018990d5774fb5b5d1dbfb2f805c9fc6796fcc4 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 16 Apr 2026 07:43:12 +0000 Subject: [PATCH 3/6] test: add bug replication tests for #704 Adds tests that reproduce the exact bugs: - BUG test: proves palette[7] (#c0c0c0) has ~1.3:1 contrast on white (invisible) - BUG test: proves old inline code bg was transparent (no contrast) - FIX test: verifies new fg (#1a1a1a) has >=3:1 contrast on white - FIX test: verifies inline code bg is now opaque - FIX test: verifies dark mode is unaffected (still uses palette[7]) --- .../test/cli/tui/theme-light-mode-704.test.ts | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 packages/opencode/test/cli/tui/theme-light-mode-704.test.ts diff --git a/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts b/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts new file mode 100644 index 0000000000..8fee9b213b --- /dev/null +++ b/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import github from "@/cli/cmd/tui/context/theme/github.json" +import solarized from "@/cli/cmd/tui/context/theme/solarized.json" +import flexoki from "@/cli/cmd/tui/context/theme/flexoki.json" + +/** + * Regression tests for issue #704 — code output renders as white text on + * light terminal backgrounds. + * + * These tests reproduce the exact bugs and verify they're fixed by testing + * the same logic paths used in production (theme.tsx). + * + * Key: each test documents what the OLD (broken) behavior was and asserts + * the NEW (fixed) behavior. Reverting the fix in theme.tsx would require + * reverting these tests too — they serve as living documentation of the bug. + */ + +// ─── Reproduce the pure functions from theme.tsx ─────────────────────────── +// These MUST match the production code. If production changes, these must too. + +type ThemeColors = Record +type Theme = ThemeColors & { _hasSelectedListItemText: boolean; thinkingOpacity: number } +type ThemeJson = { defs?: Record; theme: Record } + +function ansiToRgba(code: number): RGBA { + if (code < 16) { + const ansiColors = [ + "#000000", "#800000", "#008000", "#808000", + "#000080", "#800080", "#008080", "#c0c0c0", + "#808080", "#ff0000", "#00ff00", "#ffff00", + "#0000ff", "#ff00ff", "#00ffff", "#ffffff", + ] + return RGBA.fromHex(ansiColors[code] ?? "#000000") + } + if (code < 232) { + const index = code - 16 + const b = index % 6 + const g = Math.floor(index / 6) % 6 + const r = Math.floor(index / 36) + const val = (x: number) => (x === 0 ? 0 : x * 40 + 55) + return RGBA.fromInts(val(r), val(g), val(b)) + } + if (code < 256) { + const gray = (code - 232) * 10 + 8 + return RGBA.fromInts(gray, gray, gray) + } + return RGBA.fromInts(0, 0, 0) +} + +function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { + const defs = theme.defs ?? {} + type ColorValue = string | number | RGBA | { dark: string; light: string } + function resolveColor(c: ColorValue): RGBA { + if (c instanceof RGBA) return c + if (typeof c === "string") { + if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0) + if (c.startsWith("#")) return RGBA.fromHex(c) + if (defs[c] != null) return resolveColor(defs[c]) + if (theme.theme[c] !== undefined) return resolveColor(theme.theme[c] as ColorValue) + throw new Error("Color reference not found: " + c) + } + if (typeof c === "number") return ansiToRgba(c) + return resolveColor(c[mode]) + } + + const resolved: Record = {} + for (const [key, value] of Object.entries(theme.theme)) { + if (key === "selectedListItemText" || key === "backgroundMenu" || key === "thinkingOpacity") continue + resolved[key] = resolveColor(value as ColorValue) + } + resolved.selectedListItemText = theme.theme.selectedListItemText + ? resolveColor(theme.theme.selectedListItemText as ColorValue) + : resolved.background! + resolved.backgroundMenu = theme.theme.backgroundMenu + ? resolveColor(theme.theme.backgroundMenu as ColorValue) + : resolved.backgroundElement! + + return { ...resolved, _hasSelectedListItemText: !!theme.theme.selectedListItemText, thinkingOpacity: 0.6 } as Theme +} + +function contrastRatio(fg: RGBA, bg: RGBA): number { + function relLum(c: RGBA): number { + const [r, g, b] = c.toInts() + const srgb = [r, g, b].map((v) => { + const s = v / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + }) + return 0.2126 * srgb[0]! + 0.7152 * srgb[1]! + 0.0722 * srgb[2]! + } + const l1 = relLum(fg) + const l2 = relLum(bg) + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05) +} + +function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { + const r = base.r + (overlay.r - base.r) * alpha + const g = base.g + (overlay.g - base.g) * alpha + const b = base.b + (overlay.b - base.b) * alpha + return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)) +} + +// ─── Reproduce generateSystem with the FIX ───────────────────────────────── + +function generateGrayScale(bg: RGBA, isDark: boolean): Record { + const grays: Record = {} + const bgR = bg.r * 255, bgG = bg.g * 255, bgB = bg.b * 255 + const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + for (let i = 1; i <= 12; i++) { + const factor = i / 12.0 + let newR: number, newG: number, newB: number + if (isDark) { + if (luminance < 10) { + const gv = Math.floor(factor * 0.4 * 255) + newR = gv; newG = gv; newB = gv + } else { + const newLum = luminance + (255 - luminance) * factor * 0.4 + const ratio = newLum / luminance + newR = Math.min(bgR * ratio, 255); newG = Math.min(bgG * ratio, 255); newB = Math.min(bgB * ratio, 255) + } + } else { + if (luminance > 245) { + const gv = Math.floor(255 - factor * 0.4 * 255) + newR = gv; newG = gv; newB = gv + } else { + const newLum = luminance * (1 - factor * 0.4) + const ratio = newLum / luminance + newR = Math.max(bgR * ratio, 0); newG = Math.max(bgG * ratio, 0); newB = Math.max(bgB * ratio, 0) + } + } + grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB)) + } + return grays +} + +type TerminalColors = { + defaultBackground?: string + defaultForeground?: string + palette: string[] +} + +/** Reproduces generateSystem from theme.tsx — WITH the #704 fix applied */ +function generateSystemFixed(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { + const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) + const transparent = RGBA.fromInts(0, 0, 0, 0) + const isDark = mode === "dark" + + // THE FIX: use contrast-appropriate fallback + const fgFallback = isDark ? colors.palette[7]! : "#1a1a1a" + const fg = RGBA.fromHex(colors.defaultForeground ?? fgFallback) + + const col = (i: number) => colors.palette[i] ? RGBA.fromHex(colors.palette[i]) : ansiToRgba(i) + const grays = generateGrayScale(bg, isDark) + + return { + theme: { + primary: col(6), secondary: col(5), accent: col(6), + error: col(1), warning: col(3), success: col(2), info: col(6), + text: fg, textMuted: RGBA.fromInts(120, 120, 120), selectedListItemText: bg, + background: transparent, backgroundPanel: grays[2], backgroundElement: grays[3], backgroundMenu: grays[3], + borderSubtle: grays[6], border: grays[7], borderActive: grays[8], + diffAdded: col(2), diffRemoved: col(1), diffContext: grays[7], diffHunkHeader: grays[7], + diffHighlightAdded: col(10), diffHighlightRemoved: col(9), + diffAddedBg: tint(bg, col(2), 0.14), diffRemovedBg: tint(bg, col(1), 0.14), + diffContextBg: grays[1], diffLineNumber: grays[6], + diffAddedLineNumberBg: tint(grays[3], col(2), 0.14), + diffRemovedLineNumberBg: tint(grays[3], col(1), 0.14), + markdownText: fg, markdownHeading: fg, markdownLink: col(4), markdownLinkText: col(6), + markdownCode: col(2), markdownBlockQuote: col(3), markdownEmph: col(3), + markdownStrong: fg, markdownHorizontalRule: grays[7], + markdownListItem: col(4), markdownListEnumeration: col(6), + markdownImage: col(4), markdownImageText: col(6), markdownCodeBlock: fg, + syntaxComment: RGBA.fromInts(120, 120, 120), syntaxKeyword: col(5), syntaxFunction: col(4), + syntaxVariable: fg, syntaxString: col(2), syntaxNumber: col(3), + syntaxType: col(6), syntaxOperator: col(6), syntaxPunctuation: fg, + }, + } +} + +/** Reproduces generateSystem with the OLD (broken) behavior */ +function generateSystemBroken(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { + const result = generateSystemFixed(colors, mode) + // Revert the fix: always use palette[7] regardless of mode + const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) + const theme = result.theme as Record + theme.text = fg + theme.markdownText = fg + theme.markdownHeading = fg + theme.markdownStrong = fg + theme.markdownCodeBlock = fg + theme.syntaxVariable = fg + theme.syntaxPunctuation = fg + return result +} + +/** getSyntaxRules with the FIX: inline code uses backgroundElement */ +function getSyntaxRulesFixed(theme: Theme) { + return [ + { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode, background: theme.backgroundElement } }, + { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.backgroundElement } }, + { scope: ["default"], style: { foreground: theme.text } }, + ] +} + +/** getSyntaxRules with the OLD (broken) behavior: inline code uses background (can be transparent) */ +function getSyntaxRulesBroken(theme: Theme) { + return [ + { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode, background: theme.backgroundElement } }, + { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } }, + { scope: ["default"], style: { foreground: theme.text } }, + ] +} + +// ─── Simulated light terminal (the reporter's setup) ─────────────────────── + +const LIGHT_TERMINAL: TerminalColors = { + defaultBackground: "#ffffff", + defaultForeground: undefined, // terminal didn't report this — triggers the fallback + palette: [ + "#000000", "#800000", "#008000", "#808000", + "#000080", "#800080", "#008080", "#c0c0c0", + "#808080", "#ff0000", "#00ff00", "#ffff00", + "#0000ff", "#ff00ff", "#00ffff", "#ffffff", + ], +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe("issue #704: REPLICATING the bug (old behavior)", () => { + test("BUG: old system theme uses palette[7] (#c0c0c0) as fg on light terminal", () => { + const brokenTheme = generateSystemBroken(LIGHT_TERMINAL, "light") + const resolved = resolveTheme(brokenTheme, "light") + + // This proves the bug: palette[7] is the text color + const palette7 = RGBA.fromHex("#c0c0c0") + expect(resolved.text.equals(palette7)).toBe(true) + + // And it has terrible contrast against white + const whiteBg = RGBA.fromHex("#ffffff") + const ratio = contrastRatio(resolved.text, whiteBg) + expect(ratio).toBeLessThan(2) // ~1.3:1 = invisible + }) + + test("BUG: old inline code background is transparent on system theme", () => { + const theme = generateSystemFixed(LIGHT_TERMINAL, "light") + const resolved = resolveTheme(theme, "light") + + // System theme sets background = transparent + expect(resolved.background.a).toBe(0) + + // Old behavior: inline code used theme.background = transparent + const brokenRules = getSyntaxRulesBroken(resolved) + const inlineRule = brokenRules.find((r) => r.scope.includes("markup.raw.inline"))! + expect(inlineRule.style.background!.a).toBe(0) // transparent = no contrast protection + }) +}) + +describe("issue #704: VERIFYING the fix (new behavior)", () => { + test("FIX: new system theme uses #1a1a1a as fg on light terminal", () => { + const fixedTheme = generateSystemFixed(LIGHT_TERMINAL, "light") + const resolved = resolveTheme(fixedTheme, "light") + + const palette7 = RGBA.fromHex("#c0c0c0") + expect(resolved.text.equals(palette7)).toBe(false) // NOT the broken color + + const whiteBg = RGBA.fromHex("#ffffff") + const ratio = contrastRatio(resolved.text, whiteBg) + expect(ratio).toBeGreaterThanOrEqual(3) // readable! + }) + + test("FIX: new inline code background is opaque on system theme", () => { + const theme = generateSystemFixed(LIGHT_TERMINAL, "light") + const resolved = resolveTheme(theme, "light") + + const fixedRules = getSyntaxRulesFixed(resolved) + const inlineRule = fixedRules.find((r) => r.scope.includes("markup.raw.inline"))! + expect(inlineRule.style.background!.a).toBeGreaterThan(0) // opaque = has contrast + }) + + test("FIX: dark mode is unaffected (still uses palette[7])", () => { + const darkTerminal: TerminalColors = { ...LIGHT_TERMINAL, defaultBackground: "#1a1a1a" } + const fixedTheme = generateSystemFixed(darkTerminal, "dark") + const resolved = resolveTheme(fixedTheme, "dark") + + const palette7 = RGBA.fromHex("#c0c0c0") + expect(resolved.text.equals(palette7)).toBe(true) // dark mode still uses palette[7] + }) +}) + +describe("issue #704: named theme inline code contrast", () => { + const LIGHT_THEMES: [string, ThemeJson][] = [ + ["github", github as unknown as ThemeJson], + ["solarized", solarized as unknown as ThemeJson], + ["flexoki", flexoki as unknown as ThemeJson], + ] + + test.each(LIGHT_THEMES)( + "%s: inline code has readable contrast in light mode", + (_name, themeJson) => { + const resolved = resolveTheme(themeJson, "light") + const rules = getSyntaxRulesFixed(resolved) + const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline"))! + + const ratio = contrastRatio(inlineRule.style.foreground!, inlineRule.style.background!) + expect(ratio).toBeGreaterThanOrEqual(2) + }, + ) +}) + +describe("issue #704: COLORFGBG parsing", () => { + // Reproduces the logic added to getTerminalBackgroundColor in app.tsx + function parseCOLORFGBG(value: string): "dark" | "light" | null { + const parts = value.split(";") + const bg = parseInt(parts[parts.length - 1]) + if (isNaN(bg)) return null + return bg >= 8 ? "light" : "dark" + } + + test("COLORFGBG=0;15 (white bg) -> light", () => expect(parseCOLORFGBG("0;15")).toBe("light")) + test("COLORFGBG=15;0 (black bg) -> dark", () => expect(parseCOLORFGBG("15;0")).toBe("dark")) + test("COLORFGBG=0;7;15 (3-part) -> light", () => expect(parseCOLORFGBG("0;7;15")).toBe("light")) + test("COLORFGBG=15;0;0 (3-part) -> dark", () => expect(parseCOLORFGBG("15;0;0")).toBe("dark")) + test("invalid -> null", () => expect(parseCOLORFGBG("abc")).toBe(null)) +}) From 92bffb755de18563bd5149f4802770719c2c9973 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 16 Apr 2026 07:44:05 +0000 Subject: [PATCH 4/6] chore: clean up verbose comments in fix --- packages/opencode/src/cli/cmd/tui/app.tsx | 5 +---- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 8 ++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2f33bd4809..15c0a255dc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -165,9 +165,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { timeout = setTimeout(() => { cleanup() - // altimate_change start — fix: use COLORFGBG env var as fallback before defaulting to dark - // Many terminals (iTerm2, xterm, etc.) set COLORFGBG=; to indicate color scheme. - // When bg component >= 8, the terminal likely has a light background. + // Fallback: check COLORFGBG env var (set by many terminals) before defaulting to dark const colorfgbg = process.env.COLORFGBG if (colorfgbg) { const parts = colorfgbg.split(";") @@ -178,7 +176,6 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { } } resolve("dark") - // altimate_change end }, 1000) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 46cf5d8726..903d0e3205 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -433,11 +433,9 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" - // altimate_change start — fix: use contrast-appropriate fallback for light terminals - // palette[7] is #c0c0c0 (light gray) which is invisible on white backgrounds + // palette[7] (#c0c0c0) is invisible on light backgrounds — use dark fallback instead const fgFallback = isDark ? colors.palette[7]! : "#1a1a1a" const fg = RGBA.fromHex(colors.defaultForeground ?? fgFallback) - // altimate_change end const col = (i: number) => { const value = colors.palette[i] @@ -953,10 +951,8 @@ function getSyntaxRules(theme: Theme) { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, - // altimate_change start — fix: use backgroundElement for inline code contrast on light themes - // theme.background can be transparent, leaving inline code with no contrast protection + // backgroundElement (not background) ensures contrast when background is transparent background: theme.backgroundElement, - // altimate_change end }, }, { From 4d06787eb4b93d7532b29021729956eff0050935 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 16 Apr 2026 09:14:01 +0000 Subject: [PATCH 5/6] chore: add altimate_change markers to upstream patches --- packages/opencode/src/cli/cmd/tui/app.tsx | 3 ++- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 15c0a255dc..8a370a0a4c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -165,7 +165,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { timeout = setTimeout(() => { cleanup() - // Fallback: check COLORFGBG env var (set by many terminals) before defaulting to dark + // altimate_change start — fix: COLORFGBG fallback for light terminal detection const colorfgbg = process.env.COLORFGBG if (colorfgbg) { const parts = colorfgbg.split(";") @@ -176,6 +176,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { } } resolve("dark") + // altimate_change end }, 1000) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 903d0e3205..fc47985718 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -433,9 +433,10 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" - // palette[7] (#c0c0c0) is invisible on light backgrounds — use dark fallback instead + // altimate_change start — fix: light-mode foreground fallback const fgFallback = isDark ? colors.palette[7]! : "#1a1a1a" const fg = RGBA.fromHex(colors.defaultForeground ?? fgFallback) + // altimate_change end const col = (i: number) => { const value = colors.palette[i] @@ -951,8 +952,9 @@ function getSyntaxRules(theme: Theme) { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, - // backgroundElement (not background) ensures contrast when background is transparent + // altimate_change start — fix: inline code contrast on transparent backgrounds background: theme.backgroundElement, + // altimate_change end }, }, { From 8f747a18a59cb73dde90b4bbcc0fff5f7649d6ab Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 17 Apr 2026 12:57:28 +0530 Subject: [PATCH 6/6] fix: [#704] address consensus review feedback on light terminal detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the multi-model consensus code review on PR #712. **COLORFGBG detection**: - Extract `detectModeFromCOLORFGBG` into a pure TypeScript helper at `packages/opencode/src/cli/cmd/tui/util/terminal-detection.ts` so tests exercise the real production code (was previously inlined in `app.tsx` with test duplicates that couldn't catch regressions). - Narrow the light-mode threshold from `bg >= 8` to `bg === 7 || bg === 15`. The old `>= 8` misclassified bright red (9), blue (12), magenta (13) as "light" even though they have low luminance; it also missed index 7 (light gray), which is a common light-terminal background. - Reject out-of-range (< 0, > 15), non-numeric ("default"), and empty values explicitly rather than silently defaulting to dark. - Tolerate surrounding whitespace. - Use `parseInt(..., 10)` with an explicit radix. - Check `COLORFGBG` eagerly in `getTerminalBackgroundColor()` before issuing the OSC 11 query. Light terminals that don't support OSC 11 (common with urxvt, gnome-terminal) no longer pay the 1-second timeout on every launch. **System-theme light-mode foreground fallback**: - `generateSystem` now falls back to `colors.palette[0] ?? "#1a1a1a"` in light mode (dark mode keeps `colors.palette[7]`). Respects the user's palette when provided; still guarantees a readable near-black on white when the palette is empty. **Tests**: - Rewrite `theme-light-mode-704.test.ts` to import the real `detectModeFromCOLORFGBG` — reverting the production helper now fails the 18 COLORFGBG tests. - Add coverage for: `0;7` (light-gray bg, now light), `15;8` (dark-gray bg, now dark), `0;9`/`0;12`/`0;13` (bright-red/blue/magenta, now dark), `default;default` (Alacritty/Kitty), out-of-range values, negatives, empty string, whitespace tolerance, undefined. - Add a light-mode regression test confirming fallback prefers `palette[0]` over `#1a1a1a` when present. - Add `defaultForeground`-is-honored regression. - Add dark-mode `markup.raw.inline` regression test in `theme-light-mode.test.ts` to cover the cross-mode impact of the `backgroundElement` change. - Consolidate duplicate `COLORFGBG` tests (previously in both files). Closes #704 (follow-up to #712/#617). --- .github/meta/commit.txt | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 20 +- .../src/cli/cmd/tui/context/theme.tsx | 7 +- .../cli/cmd/tui/util/terminal-detection.ts | 23 ++ .../test/cli/tui/theme-light-mode-704.test.ts | 320 +++++++----------- .../test/cli/tui/theme-light-mode.test.ts | 78 +---- 6 files changed, 167 insertions(+), 285 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/terminal-detection.ts diff --git a/.github/meta/commit.txt b/.github/meta/commit.txt index 7be432b286..4dcad106b8 100644 --- a/.github/meta/commit.txt +++ b/.github/meta/commit.txt @@ -1 +1,3 @@ -release: v0.5.20 +release: v0.5.21 + +Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8a370a0a4c..9633124b6c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -110,10 +110,19 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider } from "./context/tui-config" import { TuiConfig } from "@/config/tui" +// altimate_change start — fix: pure helper extracted to util/terminal-detection for test coverage (#704) +import { detectModeFromCOLORFGBG } from "./util/terminal-detection" +// altimate_change end + async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY if (!process.stdin.isTTY) return "dark" + // altimate_change start — fix: check COLORFGBG eagerly to avoid 1s startup delay on terminals without OSC 11 (#704) + const envMode = detectModeFromCOLORFGBG(process.env.COLORFGBG) + if (envMode === "light") return "light" + // altimate_change end + return new Promise((resolve) => { let timeout: NodeJS.Timeout @@ -165,18 +174,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { timeout = setTimeout(() => { cleanup() - // altimate_change start — fix: COLORFGBG fallback for light terminal detection - const colorfgbg = process.env.COLORFGBG - if (colorfgbg) { - const parts = colorfgbg.split(";") - const bg = parseInt(parts[parts.length - 1]) - if (!isNaN(bg) && bg >= 8) { - resolve("light") - return - } - } resolve("dark") - // altimate_change end }, 1000) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index fc47985718..15f3a77c6e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -433,8 +433,11 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" - // altimate_change start — fix: light-mode foreground fallback - const fgFallback = isDark ? colors.palette[7]! : "#1a1a1a" + // altimate_change start — fix: light-mode foreground fallback (#704) + // Dark mode keeps palette[7] (standard light-gray fg on dark bg). + // Light mode: palette[7] is typically #c0c0c0 — invisible on white. + // Prefer the user's palette[0] (near-black) when present; #1a1a1a otherwise. + const fgFallback = isDark ? colors.palette[7]! : (colors.palette[0] ?? "#1a1a1a") const fg = RGBA.fromHex(colors.defaultForeground ?? fgFallback) // altimate_change end diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal-detection.ts b/packages/opencode/src/cli/cmd/tui/util/terminal-detection.ts new file mode 100644 index 0000000000..9952ae67bf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/terminal-detection.ts @@ -0,0 +1,23 @@ +// altimate_change start — fix: pure-TS helper extracted from app.tsx for direct test coverage (#704) +/** + * Detect terminal background mode from the COLORFGBG env var. + * + * Format is `fg;bg` or `fg;default;bg` (rxvt/urxvt). The last semicolon- + * separated component is the background palette index. Only indices that + * are canonically light (7 = light-gray, 15 = bright-white) classify as + * "light" — other bright indices (9 red, 12 blue, 13 magenta) are dark + * by luminance and must not be treated as light. + * + * Returns `null` when the value is missing, malformed (e.g. "default"), + * or outside the 0-15 ANSI range. + */ +export function detectModeFromCOLORFGBG(value: string | undefined): "dark" | "light" | null { + if (!value) return null + const parts = value.split(";") + const last = parts[parts.length - 1]?.trim() + if (!last) return null + const bg = parseInt(last, 10) + if (!Number.isInteger(bg) || bg < 0 || bg > 15) return null + return bg === 7 || bg === 15 ? "light" : "dark" +} +// altimate_change end diff --git a/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts b/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts index 8fee9b213b..d06b13d79e 100644 --- a/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts +++ b/packages/opencode/test/cli/tui/theme-light-mode-704.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { detectModeFromCOLORFGBG } from "@/cli/cmd/tui/util/terminal-detection" import github from "@/cli/cmd/tui/context/theme/github.json" import solarized from "@/cli/cmd/tui/context/theme/solarized.json" import flexoki from "@/cli/cmd/tui/context/theme/flexoki.json" @@ -8,20 +9,19 @@ import flexoki from "@/cli/cmd/tui/context/theme/flexoki.json" * Regression tests for issue #704 — code output renders as white text on * light terminal backgrounds. * - * These tests reproduce the exact bugs and verify they're fixed by testing - * the same logic paths used in production (theme.tsx). + * The COLORFGBG tests exercise the real production helper + * (`detectModeFromCOLORFGBG` in `util/terminal-detection.ts`). Reverting + * the fix in that file will cause these tests to fail. * - * Key: each test documents what the OLD (broken) behavior was and asserts - * the NEW (fixed) behavior. Reverting the fix in theme.tsx would require - * reverting these tests too — they serve as living documentation of the bug. + * The theme-level tests (system-theme foreground fallback, inline-code + * background) reproduce the logic locally rather than importing from + * `theme.tsx`. The .tsx module cannot be imported from `bun:test` + * because `@opentui/solid`'s JSX runtime types don't resolve in the + * test loader (tracked for a follow-up pure-TS extraction). The local + * copies are kept in lockstep with production via manual review. */ -// ─── Reproduce the pure functions from theme.tsx ─────────────────────────── -// These MUST match the production code. If production changes, these must too. - -type ThemeColors = Record -type Theme = ThemeColors & { _hasSelectedListItemText: boolean; thinkingOpacity: number } -type ThemeJson = { defs?: Record; theme: Record } +// ─── Pure test helpers (WCAG contrast + ANSI palette resolution) ─────────── function ansiToRgba(code: number): RGBA { if (code < 16) { @@ -48,7 +48,9 @@ function ansiToRgba(code: number): RGBA { return RGBA.fromInts(0, 0, 0) } -function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { +type ThemeJson = { defs?: Record; theme: Record } + +function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Record { const defs = theme.defs ?? {} type ColorValue = string | number | RGBA | { dark: string; light: string } function resolveColor(c: ColorValue): RGBA { @@ -63,20 +65,15 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { if (typeof c === "number") return ansiToRgba(c) return resolveColor(c[mode]) } - const resolved: Record = {} for (const [key, value] of Object.entries(theme.theme)) { if (key === "selectedListItemText" || key === "backgroundMenu" || key === "thinkingOpacity") continue resolved[key] = resolveColor(value as ColorValue) } - resolved.selectedListItemText = theme.theme.selectedListItemText - ? resolveColor(theme.theme.selectedListItemText as ColorValue) - : resolved.background! resolved.backgroundMenu = theme.theme.backgroundMenu ? resolveColor(theme.theme.backgroundMenu as ColorValue) : resolved.backgroundElement! - - return { ...resolved, _hasSelectedListItemText: !!theme.theme.selectedListItemText, thinkingOpacity: 0.6 } as Theme + return resolved } function contrastRatio(fg: RGBA, bg: RGBA): number { @@ -93,45 +90,83 @@ function contrastRatio(fg: RGBA, bg: RGBA): number { return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05) } -function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { - const r = base.r + (overlay.r - base.r) * alpha - const g = base.g + (overlay.g - base.g) * alpha - const b = base.b + (overlay.b - base.b) * alpha - return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)) -} +// ─── detectModeFromCOLORFGBG: uses REAL production helper ────────────────── -// ─── Reproduce generateSystem with the FIX ───────────────────────────────── - -function generateGrayScale(bg: RGBA, isDark: boolean): Record { - const grays: Record = {} - const bgR = bg.r * 255, bgG = bg.g * 255, bgB = bg.b * 255 - const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB - for (let i = 1; i <= 12; i++) { - const factor = i / 12.0 - let newR: number, newG: number, newB: number - if (isDark) { - if (luminance < 10) { - const gv = Math.floor(factor * 0.4 * 255) - newR = gv; newG = gv; newB = gv - } else { - const newLum = luminance + (255 - luminance) * factor * 0.4 - const ratio = newLum / luminance - newR = Math.min(bgR * ratio, 255); newG = Math.min(bgG * ratio, 255); newB = Math.min(bgB * ratio, 255) - } - } else { - if (luminance > 245) { - const gv = Math.floor(255 - factor * 0.4 * 255) - newR = gv; newG = gv; newB = gv - } else { - const newLum = luminance * (1 - factor * 0.4) - const ratio = newLum / luminance - newR = Math.max(bgR * ratio, 0); newG = Math.max(bgG * ratio, 0); newB = Math.max(bgB * ratio, 0) - } - } - grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB)) - } - return grays -} +describe("issue #704: detectModeFromCOLORFGBG (real production helper)", () => { + test("0;15 (bright white bg) -> light", () => { + expect(detectModeFromCOLORFGBG("0;15")).toBe("light") + }) + + test("0;7 (light-gray bg) -> light", () => { + expect(detectModeFromCOLORFGBG("0;7")).toBe("light") + }) + + test("15;0 (black bg) -> dark", () => { + expect(detectModeFromCOLORFGBG("15;0")).toBe("dark") + }) + + test("15;8 (dark-gray bg) -> dark", () => { + expect(detectModeFromCOLORFGBG("15;8")).toBe("dark") + }) + + test("0;9 (bright red bg) -> dark (bright != light)", () => { + expect(detectModeFromCOLORFGBG("0;9")).toBe("dark") + }) + + test("0;12 (bright blue bg) -> dark", () => { + expect(detectModeFromCOLORFGBG("0;12")).toBe("dark") + }) + + test("0;13 (bright magenta bg) -> dark", () => { + expect(detectModeFromCOLORFGBG("0;13")).toBe("dark") + }) + + test("0;7;15 (3-part, last is bg) -> light", () => { + expect(detectModeFromCOLORFGBG("0;7;15")).toBe("light") + }) + + test("15;0;0 (3-part, last is bg) -> dark", () => { + expect(detectModeFromCOLORFGBG("15;0;0")).toBe("dark") + }) + + test("default;default (Alacritty/Kitty) -> null", () => { + expect(detectModeFromCOLORFGBG("default;default")).toBe(null) + }) + + test("15;default -> null", () => { + expect(detectModeFromCOLORFGBG("15;default")).toBe(null) + }) + + test("0;99 (out-of-range) -> null", () => { + expect(detectModeFromCOLORFGBG("0;99")).toBe(null) + }) + + test("0;256 (out-of-range) -> null", () => { + expect(detectModeFromCOLORFGBG("0;256")).toBe(null) + }) + + test("0;-1 (negative) -> null", () => { + expect(detectModeFromCOLORFGBG("0;-1")).toBe(null) + }) + + test("empty string -> null", () => { + expect(detectModeFromCOLORFGBG("")).toBe(null) + }) + + test("undefined -> null", () => { + expect(detectModeFromCOLORFGBG(undefined)).toBe(null) + }) + + test("' 0;15 ' (whitespace tolerated) -> light", () => { + expect(detectModeFromCOLORFGBG(" 0;15 ")).toBe("light") + }) + + test("abc (non-numeric) -> null", () => { + expect(detectModeFromCOLORFGBG("abc")).toBe(null) + }) +}) + +// ─── Theme-level tests (pure-TS reproduction of generateSystem) ──────────── type TerminalColors = { defaultBackground?: string @@ -139,83 +174,18 @@ type TerminalColors = { palette: string[] } -/** Reproduces generateSystem from theme.tsx — WITH the #704 fix applied */ -function generateSystemFixed(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { +function generateSystemLike(colors: TerminalColors, mode: "dark" | "light") { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) - const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode === "dark" - - // THE FIX: use contrast-appropriate fallback - const fgFallback = isDark ? colors.palette[7]! : "#1a1a1a" + // Mirror of theme.tsx: light-mode fallback prefers palette[0], else #1a1a1a + const fgFallback = isDark ? colors.palette[7]! : (colors.palette[0] ?? "#1a1a1a") const fg = RGBA.fromHex(colors.defaultForeground ?? fgFallback) - - const col = (i: number) => colors.palette[i] ? RGBA.fromHex(colors.palette[i]) : ansiToRgba(i) - const grays = generateGrayScale(bg, isDark) - - return { - theme: { - primary: col(6), secondary: col(5), accent: col(6), - error: col(1), warning: col(3), success: col(2), info: col(6), - text: fg, textMuted: RGBA.fromInts(120, 120, 120), selectedListItemText: bg, - background: transparent, backgroundPanel: grays[2], backgroundElement: grays[3], backgroundMenu: grays[3], - borderSubtle: grays[6], border: grays[7], borderActive: grays[8], - diffAdded: col(2), diffRemoved: col(1), diffContext: grays[7], diffHunkHeader: grays[7], - diffHighlightAdded: col(10), diffHighlightRemoved: col(9), - diffAddedBg: tint(bg, col(2), 0.14), diffRemovedBg: tint(bg, col(1), 0.14), - diffContextBg: grays[1], diffLineNumber: grays[6], - diffAddedLineNumberBg: tint(grays[3], col(2), 0.14), - diffRemovedLineNumberBg: tint(grays[3], col(1), 0.14), - markdownText: fg, markdownHeading: fg, markdownLink: col(4), markdownLinkText: col(6), - markdownCode: col(2), markdownBlockQuote: col(3), markdownEmph: col(3), - markdownStrong: fg, markdownHorizontalRule: grays[7], - markdownListItem: col(4), markdownListEnumeration: col(6), - markdownImage: col(4), markdownImageText: col(6), markdownCodeBlock: fg, - syntaxComment: RGBA.fromInts(120, 120, 120), syntaxKeyword: col(5), syntaxFunction: col(4), - syntaxVariable: fg, syntaxString: col(2), syntaxNumber: col(3), - syntaxType: col(6), syntaxOperator: col(6), syntaxPunctuation: fg, - }, - } -} - -/** Reproduces generateSystem with the OLD (broken) behavior */ -function generateSystemBroken(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { - const result = generateSystemFixed(colors, mode) - // Revert the fix: always use palette[7] regardless of mode - const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) - const theme = result.theme as Record - theme.text = fg - theme.markdownText = fg - theme.markdownHeading = fg - theme.markdownStrong = fg - theme.markdownCodeBlock = fg - theme.syntaxVariable = fg - theme.syntaxPunctuation = fg - return result -} - -/** getSyntaxRules with the FIX: inline code uses backgroundElement */ -function getSyntaxRulesFixed(theme: Theme) { - return [ - { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode, background: theme.backgroundElement } }, - { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.backgroundElement } }, - { scope: ["default"], style: { foreground: theme.text } }, - ] -} - -/** getSyntaxRules with the OLD (broken) behavior: inline code uses background (can be transparent) */ -function getSyntaxRulesBroken(theme: Theme) { - return [ - { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode, background: theme.backgroundElement } }, - { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } }, - { scope: ["default"], style: { foreground: theme.text } }, - ] + return { bg, fg } } -// ─── Simulated light terminal (the reporter's setup) ─────────────────────── - const LIGHT_TERMINAL: TerminalColors = { defaultBackground: "#ffffff", - defaultForeground: undefined, // terminal didn't report this — triggers the fallback + defaultForeground: undefined, palette: [ "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", @@ -224,70 +194,38 @@ const LIGHT_TERMINAL: TerminalColors = { ], } -// ─── Tests ───────────────────────────────────────────────────────────────── - -describe("issue #704: REPLICATING the bug (old behavior)", () => { - test("BUG: old system theme uses palette[7] (#c0c0c0) as fg on light terminal", () => { - const brokenTheme = generateSystemBroken(LIGHT_TERMINAL, "light") - const resolved = resolveTheme(brokenTheme, "light") - - // This proves the bug: palette[7] is the text color - const palette7 = RGBA.fromHex("#c0c0c0") - expect(resolved.text.equals(palette7)).toBe(true) - - // And it has terrible contrast against white - const whiteBg = RGBA.fromHex("#ffffff") - const ratio = contrastRatio(resolved.text, whiteBg) - expect(ratio).toBeLessThan(2) // ~1.3:1 = invisible - }) - - test("BUG: old inline code background is transparent on system theme", () => { - const theme = generateSystemFixed(LIGHT_TERMINAL, "light") - const resolved = resolveTheme(theme, "light") - - // System theme sets background = transparent - expect(resolved.background.a).toBe(0) - - // Old behavior: inline code used theme.background = transparent - const brokenRules = getSyntaxRulesBroken(resolved) - const inlineRule = brokenRules.find((r) => r.scope.includes("markup.raw.inline"))! - expect(inlineRule.style.background!.a).toBe(0) // transparent = no contrast protection +describe("issue #704: system theme light-mode foreground fallback", () => { + test("light mode: fallback is not palette[7] (#c0c0c0)", () => { + const { fg } = generateSystemLike(LIGHT_TERMINAL, "light") + expect(fg.equals(RGBA.fromHex("#c0c0c0"))).toBe(false) }) -}) - -describe("issue #704: VERIFYING the fix (new behavior)", () => { - test("FIX: new system theme uses #1a1a1a as fg on light terminal", () => { - const fixedTheme = generateSystemFixed(LIGHT_TERMINAL, "light") - const resolved = resolveTheme(fixedTheme, "light") - - const palette7 = RGBA.fromHex("#c0c0c0") - expect(resolved.text.equals(palette7)).toBe(false) // NOT the broken color + test("light mode: fallback has WCAG-AA contrast on white", () => { + const { fg } = generateSystemLike(LIGHT_TERMINAL, "light") const whiteBg = RGBA.fromHex("#ffffff") - const ratio = contrastRatio(resolved.text, whiteBg) - expect(ratio).toBeGreaterThanOrEqual(3) // readable! + expect(contrastRatio(fg, whiteBg)).toBeGreaterThanOrEqual(4.5) }) - test("FIX: new inline code background is opaque on system theme", () => { - const theme = generateSystemFixed(LIGHT_TERMINAL, "light") - const resolved = resolveTheme(theme, "light") - - const fixedRules = getSyntaxRulesFixed(resolved) - const inlineRule = fixedRules.find((r) => r.scope.includes("markup.raw.inline"))! - expect(inlineRule.style.background!.a).toBeGreaterThan(0) // opaque = has contrast + test("light mode: fallback respects user palette[0] when provided", () => { + const custom: TerminalColors = { ...LIGHT_TERMINAL, palette: ["#222244", ...LIGHT_TERMINAL.palette.slice(1)] } + const { fg } = generateSystemLike(custom, "light") + expect(fg.equals(RGBA.fromHex("#222244"))).toBe(true) }) - test("FIX: dark mode is unaffected (still uses palette[7])", () => { + test("dark mode regression: fallback is palette[7]", () => { const darkTerminal: TerminalColors = { ...LIGHT_TERMINAL, defaultBackground: "#1a1a1a" } - const fixedTheme = generateSystemFixed(darkTerminal, "dark") - const resolved = resolveTheme(fixedTheme, "dark") + const { fg } = generateSystemLike(darkTerminal, "dark") + expect(fg.equals(RGBA.fromHex("#c0c0c0"))).toBe(true) + }) - const palette7 = RGBA.fromHex("#c0c0c0") - expect(resolved.text.equals(palette7)).toBe(true) // dark mode still uses palette[7] + test("defaultForeground is always honored when provided", () => { + const explicit: TerminalColors = { ...LIGHT_TERMINAL, defaultForeground: "#113355" } + const { fg } = generateSystemLike(explicit, "light") + expect(fg.equals(RGBA.fromHex("#113355"))).toBe(true) }) }) -describe("issue #704: named theme inline code contrast", () => { +describe("issue #704: markup.raw.inline uses backgroundElement (named themes)", () => { const LIGHT_THEMES: [string, ThemeJson][] = [ ["github", github as unknown as ThemeJson], ["solarized", solarized as unknown as ThemeJson], @@ -295,30 +233,14 @@ describe("issue #704: named theme inline code contrast", () => { ] test.each(LIGHT_THEMES)( - "%s: inline code has readable contrast in light mode", + "%s light: backgroundElement is opaque and gives markdownCode visible contrast", (_name, themeJson) => { const resolved = resolveTheme(themeJson, "light") - const rules = getSyntaxRulesFixed(resolved) - const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline"))! - - const ratio = contrastRatio(inlineRule.style.foreground!, inlineRule.style.background!) + expect(resolved.backgroundElement!.a).toBeGreaterThan(0) + const ratio = contrastRatio(resolved.markdownCode!, resolved.backgroundElement!) + // 2.0 matches the threshold used elsewhere in this suite — inline code + // colors are syntax-intent (semantic) and don't need full WCAG-AA text contrast. expect(ratio).toBeGreaterThanOrEqual(2) }, ) }) - -describe("issue #704: COLORFGBG parsing", () => { - // Reproduces the logic added to getTerminalBackgroundColor in app.tsx - function parseCOLORFGBG(value: string): "dark" | "light" | null { - const parts = value.split(";") - const bg = parseInt(parts[parts.length - 1]) - if (isNaN(bg)) return null - return bg >= 8 ? "light" : "dark" - } - - test("COLORFGBG=0;15 (white bg) -> light", () => expect(parseCOLORFGBG("0;15")).toBe("light")) - test("COLORFGBG=15;0 (black bg) -> dark", () => expect(parseCOLORFGBG("15;0")).toBe("dark")) - test("COLORFGBG=0;7;15 (3-part) -> light", () => expect(parseCOLORFGBG("0;7;15")).toBe("light")) - test("COLORFGBG=15;0;0 (3-part) -> dark", () => expect(parseCOLORFGBG("15;0;0")).toBe("dark")) - test("invalid -> null", () => expect(parseCOLORFGBG("abc")).toBe(null)) -}) diff --git a/packages/opencode/test/cli/tui/theme-light-mode.test.ts b/packages/opencode/test/cli/tui/theme-light-mode.test.ts index 125f67f5c1..7ba07403bb 100644 --- a/packages/opencode/test/cli/tui/theme-light-mode.test.ts +++ b/packages/opencode/test/cli/tui/theme-light-mode.test.ts @@ -352,85 +352,19 @@ describe("dark theme: regression check", () => { expect(defaultRule.style.foreground!.a).toBeGreaterThan(0) }, ) -}) - -// ─── Tests for #704 fixes ────────────────────────────────────────────────── - -describe("light theme: markup.raw.inline uses backgroundElement (issue #704)", () => { - test.each(LIGHT_THEMES)( - "%s: markup.raw.inline has non-transparent background", - (_name, themeJson) => { - const resolved = resolveTheme(themeJson, "light") - const rules = getSyntaxRules(resolved) - const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline")) - expect(inlineRule).toBeDefined() - expect(inlineRule!.style.background).toBeDefined() - // backgroundElement should never be fully transparent - expect(inlineRule!.style.background!.a).toBeGreaterThan(0) - }, - ) - - test.each(LIGHT_THEMES)( - "%s: markup.raw.inline foreground is readable on its background", + test.each(DARK_THEMES)( + "%s: markup.raw.inline background is opaque in dark mode (issue #704 cross-mode regression)", (_name, themeJson) => { - const resolved = resolveTheme(themeJson, "light") + const resolved = resolveTheme(themeJson, "dark") const rules = getSyntaxRules(resolved) const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline"))! - const fg = inlineRule.style.foreground! - const bg = inlineRule.style.background! + expect(inlineRule.style.background).toBeDefined() + expect(inlineRule.style.background!.a).toBeGreaterThan(0) - const ratio = contrastRatio(fg, bg) + const ratio = contrastRatio(inlineRule.style.foreground!, inlineRule.style.background!) expect(ratio).toBeGreaterThanOrEqual(2) }, ) }) - -describe("system theme: light mode foreground fallback (issue #704)", () => { - // Simulate what generateSystem does when defaultForeground is missing - // and mode is "light" — the fallback should NOT use palette[7] (#c0c0c0) - test("light mode fallback foreground is dark, not #c0c0c0", () => { - const PALETTE_7 = RGBA.fromHex("#c0c0c0") - const LIGHT_FALLBACK = RGBA.fromHex("#1a1a1a") - const WHITE_BG = RGBA.fromHex("#ffffff") - - // In light mode, we should use #1a1a1a, not #c0c0c0 - const ratio = contrastRatio(LIGHT_FALLBACK, WHITE_BG) - expect(ratio).toBeGreaterThanOrEqual(3) - - // palette[7] would be nearly invisible - const badRatio = contrastRatio(PALETTE_7, WHITE_BG) - expect(badRatio).toBeLessThan(2) // confirms the bug - }) -}) - -describe("COLORFGBG detection (issue #704)", () => { - // Test the parsing logic used in getTerminalBackgroundColor fallback - function parseCOLORFGBG(value: string): "dark" | "light" | null { - const parts = value.split(";") - const bg = parseInt(parts[parts.length - 1]) - if (isNaN(bg)) return null - return bg >= 8 ? "light" : "dark" - } - - test("COLORFGBG=0;15 -> light (white bg)", () => { - expect(parseCOLORFGBG("0;15")).toBe("light") - }) - - test("COLORFGBG=15;0 -> dark (black bg)", () => { - expect(parseCOLORFGBG("15;0")).toBe("dark") - }) - - test("COLORFGBG=0;7;15 -> light (3-part, last is bg)", () => { - expect(parseCOLORFGBG("0;7;15")).toBe("light") - }) - - test("COLORFGBG=15;0;0 -> dark (3-part, last is bg)", () => { - expect(parseCOLORFGBG("15;0;0")).toBe("dark") - }) - - test("invalid COLORFGBG -> null", () => { - expect(parseCOLORFGBG("abc")).toBe(null) - }) -})