Simon Klee 1 день назад
Родитель
Сommit
2cae0239b2

+ 410 - 60
packages/opencode/src/cli/cmd/run/theme.ts

@@ -2,14 +2,10 @@
 //
 // Derives scrollback and footer colors from the terminal's actual palette.
 // resolveRunTheme() queries the renderer for the terminal's 16-color palette,
-// detects dark/light mode, and maps through the TUI's theme system to produce
-// a RunTheme. Falls back to a hardcoded dark-mode palette if detection fails.
-//
-// The theme has three parts:
-//   entry  → per-EntryKind colors for plain scrollback text
-//   footer → highlight, muted, text, surface, and line colors for the footer
-//   block  → richer text/syntax/diff colors for static tool snapshots
-import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput } from "@opentui/core"
+// detects dark/light mode, builds a small system theme locally, and maps it to
+// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
+// palette if detection fails.
+import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
 import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
 import type { EntryKind } from "./types"
 
@@ -58,29 +54,37 @@ export type RunTheme = {
   block: RunBlockTheme
 }
 
+type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
+type HexColor = `#${string}`
+type RefName = string
+type Variant = {
+  dark: HexColor | RefName
+  light: HexColor | RefName
+}
+type ColorValue = HexColor | RefName | Variant | RGBA | number
+type ThemeJson = {
+  defs?: Record<string, HexColor | RefName>
+  theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
+    selectedListItemText?: ColorValue
+    backgroundMenu?: ColorValue
+    thinkingOpacity?: number
+  }
+}
+
 export const transparent = RGBA.fromValues(0, 0, 0, 0)
 
 function alpha(color: RGBA, value: number): RGBA {
-  const a = Math.max(0, Math.min(1, value))
-  return RGBA.fromValues(color.r, color.g, color.b, a)
+  return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
 }
 
 function rgba(hex: string, value?: number): RGBA {
   const color = RGBA.fromHex(hex)
-  if (value === undefined) {
-    return color
-  }
-
-  return alpha(color, value)
+  return value === undefined ? color : alpha(color, value)
 }
 
 function mode(bg: RGBA): "dark" | "light" {
   const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
-  if (lum > 0.5) {
-    return "light"
-  }
-
-  return "dark"
+  return lum > 0.5 ? "light" : "dark"
 }
 
 function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
@@ -99,46 +103,398 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
   )
 }
 
-function blend(color: RGBA, bg: RGBA): RGBA {
-  if (color.a >= 1) {
-    return color
+function ansiToRgba(code: number): RGBA {
+  if (code < 16) {
+    const ansi = [
+      "#000000",
+      "#800000",
+      "#008000",
+      "#808000",
+      "#000080",
+      "#800080",
+      "#008080",
+      "#c0c0c0",
+      "#808080",
+      "#ff0000",
+      "#00ff00",
+      "#ffff00",
+      "#0000ff",
+      "#ff00ff",
+      "#00ffff",
+      "#ffffff",
+    ]
+    return RGBA.fromHex(ansi[code] ?? "#000000")
   }
 
-  return RGBA.fromValues(
-    bg.r + (color.r - bg.r) * color.a,
-    bg.g + (color.g - bg.g) * color.a,
-    bg.b + (color.b - bg.b) * color.a,
-    1,
+  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 value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
+    return RGBA.fromInts(value(r), value(g), value(b))
+  }
+
+  if (code < 256) {
+    const gray = (code - 232) * 10 + 8
+    return RGBA.fromInts(gray, gray, gray)
+  }
+
+  return RGBA.fromInts(0, 0, 0)
+}
+
+function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
+  return RGBA.fromInts(
+    Math.round((base.r + (overlay.r - base.r) * value) * 255),
+    Math.round((base.g + (overlay.g - base.g) * value) * 255),
+    Math.round((base.b + (overlay.b - base.b) * value) * 255),
   )
 }
 
-export function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
-  if (!style) {
-    return undefined
+function luminance(color: RGBA) {
+  return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
+}
+
+function chroma(color: RGBA) {
+  return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
+}
+
+export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
+  const defs = theme.defs ?? {}
+
+  const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
+    if (value instanceof RGBA) return value
+
+    if (typeof value === "number") {
+      return ansiToRgba(value)
+    }
+
+    if (typeof value !== "string") {
+      return resolveColor(value[pick], chain)
+    }
+
+    if (value === "transparent" || value === "none") {
+      return RGBA.fromInts(0, 0, 0, 0)
+    }
+
+    if (value.startsWith("#")) {
+      return RGBA.fromHex(value)
+    }
+
+    if (chain.includes(value)) {
+      throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
+    }
+
+    const next = defs[value] ?? theme.theme[value as ThemeColor]
+    if (next === undefined) {
+      throw new Error(`Color reference "${value}" not found in defs or theme`)
+    }
+
+    return resolveColor(next, [...chain, value])
+  }
+
+  const resolved = Object.fromEntries(
+    Object.entries(theme.theme)
+      .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
+      .map(([key, value]) => [key, resolveColor(value as ColorValue)]),
+  ) as Partial<Record<ThemeColor, RGBA>>
+
+  return {
+    ...(resolved as Record<ThemeColor, RGBA>),
+    selectedListItemText:
+      theme.theme.selectedListItemText === undefined
+        ? resolved.background!
+        : resolveColor(theme.theme.selectedListItemText),
+    backgroundMenu:
+      theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
+    thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
+  }
+}
+
+function pickPrimaryColor(
+  bg: RGBA,
+  candidates: Array<{
+    key: string
+    color: RGBA | undefined
+  }>,
+) {
+  return candidates
+    .flatMap((item) => {
+      if (!item.color) return []
+      const contrast = Math.abs(luminance(item.color) - luminance(bg))
+      const vivid = chroma(item.color)
+      if (contrast < 0.16 || vivid < 0.12) return []
+      return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
+    })
+    .sort((a, b) => b.score - a.score)[0]
+}
+
+function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> {
+  const r = bg.r * 255
+  const g = bg.g * 255
+  const b = bg.b * 255
+  const lum = 0.299 * r + 0.587 * g + 0.114 * b
+  const cast = 0.25 * (1 - chroma(bg)) ** 2
+
+  const gray = (level: number) => {
+    const factor = level / 12
+
+    if (isDark && lum < 10) {
+      const value = Math.floor(factor * 0.4 * 255)
+      return RGBA.fromInts(value, value, value)
+    }
+
+    if (!isDark && lum > 245) {
+      const value = Math.floor(255 - factor * 0.4 * 255)
+      return RGBA.fromInts(value, value, value)
+    }
+
+    const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
+    const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
+    if (cast === 0) return tone
+
+    const ratio = lum === 0 ? 0 : value / lum
+    return tint(
+      tone,
+      RGBA.fromInts(
+        Math.floor(Math.max(0, Math.min(r * ratio, 255))),
+        Math.floor(Math.max(0, Math.min(g * ratio, 255))),
+        Math.floor(Math.max(0, Math.min(b * ratio, 255))),
+      ),
+      cast,
+    )
   }
 
-  return SyntaxStyle.fromStyles(
-    Object.fromEntries(
-      [...style.getAllStyles()].map(([name, value]) => [
-        name,
-        {
-          ...value,
-          fg: value.fg ? blend(value.fg, bg) : value.fg,
-          bg: value.bg ? blend(value.bg, bg) : value.bg,
-        },
-      ]),
-    ),
-  )
+  return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
 }
 
-function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: SyntaxStyle): RunTheme {
-  const bg = theme.background
-  const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, bg)
-  subtleSyntax?.destroy()
-  const pane = theme.backgroundElement
-  const shade = fade(pane, bg, 0.12, 0.56, 0.72)
-  const surface = fade(pane, bg, 0.18, 0.76, 0.9)
-  const line = fade(pane, bg, 0.24, 0.9, 0.98)
+function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
+  const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
+  const gray = isDark
+    ? lum < 10
+      ? 180
+      : Math.min(Math.floor(160 + lum * 0.3), 200)
+    : lum > 245
+      ? 75
+      : Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
+
+  return RGBA.fromInts(gray, gray, gray)
+}
+
+export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
+  const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
+  const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
+  const isDark = pick === "dark"
+  const grays = generateGrayScale(bg, isDark)
+  const textMuted = generateMutedTextColor(bg, isDark)
+
+  const color = (index: number) => {
+    const value = colors.palette[index]
+    return value ? RGBA.fromHex(value) : ansiToRgba(index)
+  }
+
+  const ansi = {
+    red: color(1),
+    green: color(2),
+    yellow: color(3),
+    blue: color(4),
+    magenta: color(5),
+    cyan: color(6),
+    red_bright: color(9),
+    green_bright: color(10),
+  }
+
+  const diff_alpha = isDark ? 0.22 : 0.14
+  const diff_context_bg = grays[2]
+  const primary =
+    pickPrimaryColor(bg, [
+      {
+        key: "cursor",
+        color: colors.cursorColor ? RGBA.fromHex(colors.cursorColor) : undefined,
+      },
+      {
+        key: "selection",
+        color: colors.highlightBackground ? RGBA.fromHex(colors.highlightBackground) : undefined,
+      },
+      {
+        key: "blue",
+        color: ansi.blue,
+      },
+      {
+        key: "magenta",
+        color: ansi.magenta,
+      },
+    ]) ?? {
+      key: "blue",
+      color: ansi.blue,
+    }
+
+  return {
+    theme: {
+      primary: primary.color,
+      secondary: primary.key === "magenta" ? ansi.blue : ansi.magenta,
+      accent: primary.color,
+      error: ansi.red,
+      warning: ansi.yellow,
+      success: ansi.green,
+      info: ansi.cyan,
+      text: fg,
+      textMuted,
+      selectedListItemText: bg,
+      background: RGBA.fromValues(bg.r, bg.g, bg.b, 0),
+      backgroundPanel: grays[2],
+      backgroundElement: grays[3],
+      backgroundMenu: grays[3],
+      borderSubtle: grays[6],
+      border: grays[7],
+      borderActive: grays[8],
+      diffAdded: ansi.green,
+      diffRemoved: ansi.red,
+      diffContext: grays[7],
+      diffHunkHeader: grays[7],
+      diffHighlightAdded: ansi.green_bright,
+      diffHighlightRemoved: ansi.red_bright,
+      diffAddedBg: tint(bg, ansi.green, diff_alpha),
+      diffRemovedBg: tint(bg, ansi.red, diff_alpha),
+      diffContextBg: diff_context_bg,
+      diffLineNumber: textMuted,
+      diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
+      diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
+      markdownText: fg,
+      markdownHeading: fg,
+      markdownLink: ansi.blue,
+      markdownLinkText: ansi.cyan,
+      markdownCode: ansi.green,
+      markdownBlockQuote: ansi.yellow,
+      markdownEmph: ansi.yellow,
+      markdownStrong: fg,
+      markdownHorizontalRule: grays[7],
+      markdownListItem: ansi.blue,
+      markdownListEnumeration: ansi.cyan,
+      markdownImage: ansi.blue,
+      markdownImageText: ansi.cyan,
+      markdownCodeBlock: fg,
+      syntaxComment: textMuted,
+      syntaxKeyword: ansi.magenta,
+      syntaxFunction: ansi.blue,
+      syntaxVariable: fg,
+      syntaxString: ansi.green,
+      syntaxNumber: ansi.yellow,
+      syntaxType: ansi.cyan,
+      syntaxOperator: ansi.cyan,
+      syntaxPunctuation: fg,
+    },
+  }
+}
+
+function generateSyntax(theme: TuiThemeCurrent) {
+  return SyntaxStyle.fromTheme([
+    {
+      scope: ["default"],
+      style: {
+        foreground: theme.text,
+      },
+    },
+    {
+      scope: ["comment", "comment.documentation"],
+      style: {
+        foreground: theme.syntaxComment,
+        italic: true,
+      },
+    },
+    {
+      scope: ["string", "symbol", "character", "markup.raw", "markup.raw.block", "markup.raw.inline"],
+      style: {
+        foreground: theme.markdownCode,
+      },
+    },
+    {
+      scope: ["number", "boolean", "constant"],
+      style: {
+        foreground: theme.syntaxNumber,
+      },
+    },
+    {
+      scope: ["keyword", "keyword.import", "keyword.operator"],
+      style: {
+        foreground: theme.syntaxKeyword,
+        italic: true,
+      },
+    },
+    {
+      scope: ["function", "function.call", "function.method", "function.method.call", "constructor"],
+      style: {
+        foreground: theme.syntaxFunction,
+      },
+    },
+    {
+      scope: ["type", "class", "module", "namespace"],
+      style: {
+        foreground: theme.syntaxType,
+      },
+    },
+    {
+      scope: ["operator", "punctuation.delimiter", "punctuation.special"],
+      style: {
+        foreground: theme.syntaxOperator,
+      },
+    },
+    {
+      scope: ["markup.heading"],
+      style: {
+        foreground: theme.markdownHeading,
+        bold: true,
+      },
+    },
+    {
+      scope: ["markup.link", "markup.link.url", "markup.link.label", "string.special.url"],
+      style: {
+        foreground: theme.markdownLink,
+        underline: true,
+      },
+    },
+    {
+      scope: ["diff.plus"],
+      style: {
+        foreground: theme.diffAdded,
+        background: theme.diffAddedBg,
+      },
+    },
+    {
+      scope: ["diff.minus"],
+      style: {
+        foreground: theme.diffRemoved,
+        background: theme.diffRemovedBg,
+      },
+    },
+    {
+      scope: ["diff.delta"],
+      style: {
+        foreground: theme.diffContext,
+        background: theme.diffContextBg,
+      },
+    },
+    {
+      scope: ["error"],
+      style: {
+        foreground: theme.error,
+        bold: true,
+      },
+    },
+    {
+      scope: ["warning"],
+      style: {
+        foreground: theme.warning,
+        bold: true,
+      },
+    },
+  ])
+}
+
+function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
+  const shade = fade(theme.backgroundElement, theme.background, 0.12, 0.56, 0.72)
+  const surface = fade(theme.backgroundElement, theme.background, 0.18, 0.76, 0.9)
+  const line = fade(theme.backgroundElement, theme.background, 0.24, 0.9, 0.98)
 
   return {
     background: theme.background,
@@ -151,7 +507,7 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: Syntax
       text: theme.text,
       shade,
       surface,
-      pane,
+      pane: theme.backgroundElement,
       border: theme.border,
       line,
     },
@@ -180,7 +536,6 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: Syntax
       text: theme.text,
       muted: theme.textMuted,
       syntax,
-      subtleSyntax: opaqueSubtleSyntax,
       diffAdded: theme.diffAdded,
       diffRemoved: theme.diffRemoved,
       diffAddedBg: theme.diffAddedBg,
@@ -262,13 +617,8 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
     }
 
     const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
-    const mod = await import("../tui/context/theme")
-    const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent
-    try {
-      return map(theme, mod.generateSyntax(theme), mod.generateSubtleSyntax(theme))
-    } catch {
-      return map(theme)
-    }
+    const theme = resolveTheme(generateSystem(colors, pick), pick)
+    return map(theme, generateSyntax(theme))
   } catch {
     return RUN_THEME_FALLBACK
   }

+ 7 - 59
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -513,34 +513,6 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
   return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
 }
 
-function luminance(color: RGBA) {
-  return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
-}
-
-function chroma(color: RGBA) {
-  return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
-}
-
-function pickPrimaryColor(
-  bg: RGBA,
-  candidates: Array<{
-    key: string
-    color: RGBA | undefined
-  }>,
-) {
-  return candidates
-    .flatMap((item) => {
-      if (!item.color) return []
-      const contrast = Math.abs(luminance(item.color) - luminance(bg))
-      const vivid = chroma(item.color)
-      if (contrast < 0.16 || vivid < 0.12) return []
-      return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
-    })
-    .sort((a, b) => b.score - a.score)[0]
-}
-
-// TODO: i exported this, just for keeping it simple for now, but this should
-// probably go into something shared if we decide to use this in opencode run
 export 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]!)
@@ -578,37 +550,13 @@ export function generateSystem(colors: TerminalColors, mode: "dark" | "light"):
   const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
   const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
   const diffLineNumber = textMuted
-  // The generated system theme also feeds the run footer highlight, so prefer
-  // the terminal's own cursor/selection accent when it stays legible.
-  const primary =
-    pickPrimaryColor(bg, [
-      {
-        key: "cursor",
-        color: colors.cursorColor ? RGBA.fromHex(colors.cursorColor) : undefined,
-      },
-      {
-        key: "selection",
-        color: colors.highlightBackground ? RGBA.fromHex(colors.highlightBackground) : undefined,
-      },
-      {
-        key: "blue",
-        color: ansiColors.blue,
-      },
-      {
-        key: "magenta",
-        color: ansiColors.magenta,
-      },
-    ]) ?? {
-      key: "blue",
-      color: ansiColors.blue,
-    }
 
   return {
     theme: {
-      // Fall back to blue/magenta when the terminal UI colors are too muted.
-      primary: primary.color,
-      secondary: primary.key === "magenta" ? ansiColors.blue : ansiColors.magenta,
-      accent: primary.color,
+      // Primary colors using ANSI
+      primary: ansiColors.cyan,
+      secondary: ansiColors.magenta,
+      accent: ansiColors.cyan,
 
       // Status colors using ANSI
       error: ansiColors.red,
@@ -761,11 +709,11 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
   return RGBA.fromInts(grayValue, grayValue, grayValue)
 }
 
-export function generateSyntax(theme: TuiThemeCurrent) {
+export function generateSyntax(theme: Theme) {
   return SyntaxStyle.fromTheme(getSyntaxRules(theme))
 }
 
-export function generateSubtleSyntax(theme: TuiThemeCurrent) {
+export function generateSubtleSyntax(theme: Theme) {
   const rules = getSyntaxRules(theme)
   return SyntaxStyle.fromTheme(
     rules.map((rule) => {
@@ -789,7 +737,7 @@ export function generateSubtleSyntax(theme: TuiThemeCurrent) {
   )
 }
 
-function getSyntaxRules(theme: TuiThemeCurrent) {
+function getSyntaxRules(theme: Theme) {
   return [
     {
       scope: ["default"],

+ 55 - 22
packages/opencode/test/cli/run/theme.test.ts

@@ -1,29 +1,15 @@
 import { expect, test } from "bun:test"
-import { RGBA, SyntaxStyle, type CliRenderer, type TerminalColors } from "@opentui/core"
-import { opaqueSyntaxStyle, resolveRunTheme } from "@/cli/cmd/run/theme"
-import { generateSystem, resolveTheme } from "@/cli/cmd/tui/context/theme"
-
-test("flattens subtle syntax alpha against the run background", () => {
-  const syntax = SyntaxStyle.fromStyles({
-    default: {
-      fg: RGBA.fromInts(169, 177, 214, 153),
-    },
-    emphasis: {
-      fg: RGBA.fromInts(224, 175, 104, 153),
-      italic: true,
-      bold: true,
-    },
-  })
-  const subtle = opaqueSyntaxStyle(syntax, RGBA.fromInts(42, 43, 61))
+import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
+import { generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
 
+test("resolve run theme keeps block syntax intentionally simple", async () => {
+  const theme = await resolveRunTheme(renderer("dark"))
   try {
-    expect(subtle?.getStyle("default")?.fg?.toInts()).toEqual([118, 123, 153, 255])
-    expect(subtle?.getStyle("emphasis")?.fg?.toInts()).toEqual([151, 122, 87, 255])
-    expect(subtle?.getStyle("emphasis")?.italic).toBe(true)
-    expect(subtle?.getStyle("emphasis")?.bold).toBe(true)
+    expect(theme.block.subtleSyntax).toBeUndefined()
+    expect(theme.block.syntax?.getStyle("keyword")?.fg).toEqual(RGBA.fromHex(colors.palette[5]!))
+    expect(theme.block.syntax?.getStyle("string")?.fg).toEqual(RGBA.fromHex(colors.palette[2]!))
   } finally {
-    syntax.destroy()
-    subtle?.destroy()
+    theme.block.syntax?.destroy()
   }
 })
 
@@ -66,6 +52,25 @@ function renderer(themeMode: "dark" | "light") {
   return item as CliRenderer
 }
 
+function spread(color: RGBA) {
+  const [r, g, b] = color.toInts()
+  return Math.max(r, g, b) - Math.min(r, g, b)
+}
+
+function system(defaultBackground: string, defaultForeground: string, mode: "dark" | "light") {
+  return resolveTheme(
+    generateSystem(
+      {
+        ...colors,
+        defaultBackground,
+        defaultForeground,
+      },
+      mode,
+    ),
+    mode,
+  )
+}
+
 test("system theme uses terminal ui colors for primary", () => {
   const theme = resolveTheme(generateSystem(colors, "dark"), "dark")
 
@@ -79,3 +84,31 @@ test("resolve run theme uses the system primary for footer highlight", async ()
 
   expect(theme.footer.highlight).toEqual(expected.primary)
 })
+
+test("system theme keeps dark surfaces close to neutral on colored backgrounds", () => {
+  const theme = system("#002b36", "#93a1a1", "dark")
+
+  expect(spread(theme.backgroundPanel)).toBeLessThan(25)
+  expect(spread(theme.backgroundElement)).toBeLessThan(25)
+})
+
+test("system theme keeps light surfaces close to neutral on warm backgrounds", () => {
+  const theme = system("#fbf1c7", "#3c3836", "light")
+
+  expect(spread(theme.backgroundPanel)).toBeLessThan(20)
+  expect(spread(theme.backgroundElement)).toBeLessThan(20)
+})
+
+test("system theme keeps dark surfaces neutral on saturated backgrounds", () => {
+  const theme = system("#0000ff", "#ffffff", "dark")
+
+  expect(spread(theme.backgroundPanel)).toBeLessThan(5)
+  expect(spread(theme.backgroundElement)).toBeLessThan(5)
+})
+
+test("system theme keeps light surfaces neutral on saturated backgrounds", () => {
+  const theme = system("#ffff00", "#000000", "light")
+
+  expect(spread(theme.backgroundPanel)).toBeLessThan(5)
+  expect(spread(theme.backgroundElement)).toBeLessThan(5)
+})