Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .github/meta/commit.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
release: v0.5.20
release: v0.5.21

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,17 @@ 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: 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

const col = (i: number) => {
const value = colors.palette[i]
if (value) return RGBA.fromHex(value)
Expand Down Expand Up @@ -948,7 +955,9 @@ 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: inline code contrast on transparent backgrounds
background: theme.backgroundElement,
// altimate_change end
},
},
{
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/terminal-detection.ts
Original file line number Diff line number Diff line change
@@ -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
246 changes: 246 additions & 0 deletions packages/opencode/test/cli/tui/theme-light-mode-704.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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"

/**
* Regression tests for issue #704 — code output renders as white text on
* light terminal backgrounds.
*
* 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.
*
* 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.
*/

// ─── Pure test helpers (WCAG contrast + ANSI palette resolution) ───────────

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)
}

type ThemeJson = { defs?: Record<string, string>; theme: Record<string, unknown> }

function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Record<string, RGBA> {
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<string, RGBA> = {}
for (const [key, value] of Object.entries(theme.theme)) {
if (key === "selectedListItemText" || key === "backgroundMenu" || key === "thinkingOpacity") continue
resolved[key] = resolveColor(value as ColorValue)
}
resolved.backgroundMenu = theme.theme.backgroundMenu
? resolveColor(theme.theme.backgroundMenu as ColorValue)
: resolved.backgroundElement!
return resolved
}

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)
}

// ─── detectModeFromCOLORFGBG: uses REAL production helper ──────────────────

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
defaultForeground?: string
palette: string[]
}

function generateSystemLike(colors: TerminalColors, mode: "dark" | "light") {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const isDark = mode === "dark"
// 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)
return { bg, fg }
}

const LIGHT_TERMINAL: TerminalColors = {
defaultBackground: "#ffffff",
defaultForeground: undefined,
palette: [
"#000000", "#800000", "#008000", "#808000",
"#000080", "#800080", "#008080", "#c0c0c0",
"#808080", "#ff0000", "#00ff00", "#ffff00",
"#0000ff", "#ff00ff", "#00ffff", "#ffffff",
],
}

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)
})

test("light mode: fallback has WCAG-AA contrast on white", () => {
const { fg } = generateSystemLike(LIGHT_TERMINAL, "light")
const whiteBg = RGBA.fromHex("#ffffff")
expect(contrastRatio(fg, whiteBg)).toBeGreaterThanOrEqual(4.5)
})

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("dark mode regression: fallback is palette[7]", () => {
const darkTerminal: TerminalColors = { ...LIGHT_TERMINAL, defaultBackground: "#1a1a1a" }
const { fg } = generateSystemLike(darkTerminal, "dark")
expect(fg.equals(RGBA.fromHex("#c0c0c0"))).toBe(true)
})

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: markup.raw.inline uses backgroundElement (named themes)", () => {
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 light: backgroundElement is opaque and gives markdownCode visible contrast",
(_name, themeJson) => {
const resolved = resolveTheme(themeJson, "light")
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)
},
)
})
17 changes: 16 additions & 1 deletion packages/opencode/test/cli/tui/theme-light-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down Expand Up @@ -352,4 +352,19 @@ describe("dark theme: regression check", () => {
expect(defaultRule.style.foreground!.a).toBeGreaterThan(0)
},
)

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, "dark")
const rules = getSyntaxRules(resolved)

const inlineRule = rules.find((r) => r.scope.includes("markup.raw.inline"))!
expect(inlineRule.style.background).toBeDefined()
expect(inlineRule.style.background!.a).toBeGreaterThan(0)

const ratio = contrastRatio(inlineRule.style.foreground!, inlineRule.style.background!)
expect(ratio).toBeGreaterThanOrEqual(2)
},
)
})
Loading