diff options
| author | Ariane Emory <[email protected]> | 2025-11-23 02:51:07 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-11-23 01:51:07 -0600 |
| commit | 7d11986a0a2ac0386a4289a4b05c6ea5fc598ba6 (patch) | |
| tree | 139e562df70e7bfcc98edc8810b5379848ce0a88 | |
| parent | d75d90c53e9c4aff82d70e9af1fb2b01a676d7ab (diff) | |
| download | opencode-7d11986a0a2ac0386a4289a4b05c6ea5fc598ba6.tar.gz opencode-7d11986a0a2ac0386a4289a4b05c6ea5fc598ba6.zip | |
feature: optional selectedListItemText element in themes and luminance-based fallback to solve 4369 (#4572)
Co-authored-by: knanao <[email protected]>
Co-authored-by: knanao <[email protected]>
8 files changed, 90 insertions, 30 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3029eafcc..8371c395f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" -import { useTheme } from "@tui/context/theme" +import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { Locale } from "@/util/locale" @@ -455,7 +455,7 @@ export function Autocomplete(props: { {...SplitBorder} borderColor={theme.border} > - <box backgroundColor={theme.backgroundElement} height={height()}> + <box backgroundColor={theme.backgroundMenu} height={height()}> <For each={options()} fallback={ @@ -471,11 +471,11 @@ export function Autocomplete(props: { backgroundColor={index() === store.selected ? theme.primary : undefined} flexDirection="row" > - <text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}> + <text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}> {option.display} </text> <Show when={option.description}> - <text fg={index() === store.selected ? theme.background : theme.textMuted} wrapMode="none"> + <text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none"> {option.description} </text> </Show> diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b04cb7c60..f2e97ff23 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -791,10 +791,17 @@ export function Prompt(props: PromptProps) { height={1} border={["bottom"]} borderColor={theme.backgroundElement} - customBorderChars={{ - ...EmptyBorder, - horizontal: "▀", - }} + customBorderChars={ + theme.background.a != 0 + ? { + ...EmptyBorder, + horizontal: "▀", + } + : { + ...EmptyBorder, + horizontal: " ", + } + } /> </box> <box flexDirection="row" justifyContent="space-between"> diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index abca5ba2a..4e3cc3531 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -33,7 +33,7 @@ import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" -type Theme = { +type ThemeColors = { primary: RGBA secondary: RGBA accent: RGBA @@ -43,9 +43,11 @@ type Theme = { info: RGBA text: RGBA textMuted: RGBA + selectedListItemText: RGBA background: RGBA backgroundPanel: RGBA backgroundElement: RGBA + backgroundMenu: RGBA border: RGBA borderActive: RGBA borderSubtle: RGBA @@ -86,6 +88,27 @@ type Theme = { syntaxPunctuation: RGBA } +type Theme = ThemeColors & { + _hasSelectedListItemText: boolean +} + +export function selectedForeground(theme: Theme): RGBA { + // If theme explicitly defines selectedListItemText, use it + if (theme._hasSelectedListItemText) { + return theme.selectedListItemText + } + + // For transparent backgrounds, calculate contrast based on primary color + if (theme.background.a === 0) { + const { r, g, b } = theme.primary + const luminance = 0.299 * r + 0.587 * g + 0.114 * b + return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255) + } + + // Fall back to background color + return theme.background +} + type HexColor = `#${string}` type RefName = string type Variant = { @@ -96,7 +119,10 @@ type ColorValue = HexColor | RefName | Variant | RGBA type ThemeJson = { $schema?: string defs?: Record<string, HexColor | RefName> - theme: Record<keyof Theme, ColorValue> + theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & { + selectedListItemText?: ColorValue + backgroundMenu?: ColorValue + } } export const DEFAULT_THEMES: Record<string, ThemeJson> = { @@ -137,19 +163,44 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { if (defs[c]) { return resolveColor(defs[c]) - } else if (theme.theme[c as keyof Theme]) { - return resolveColor(theme.theme[c as keyof Theme]) + } else if (theme.theme[c as keyof ThemeColors] !== undefined) { + return resolveColor(theme.theme[c as keyof ThemeColors]!) } else { throw new Error(`Color reference "${c}" not found in defs or theme`) } } return resolveColor(c[mode]) } - return Object.fromEntries( - Object.entries(theme.theme).map(([key, value]) => { - return [key, resolveColor(value)] - }), - ) as Theme + + const resolved = Object.fromEntries( + Object.entries(theme.theme) + .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu") + .map(([key, value]) => { + return [key, resolveColor(value)] + }), + ) as Partial<ThemeColors> + + // Handle selectedListItemText separately since it's optional + const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined + if (hasSelectedListItemText) { + resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!) + } else { + // Backward compatibility: if selectedListItemText is not defined, use background color + // This preserves the current behavior for all existing themes + resolved.selectedListItemText = resolved.background + } + + // Handle backgroundMenu - optional with fallback to backgroundElement + if (theme.theme.backgroundMenu !== undefined) { + resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu) + } else { + resolved.backgroundMenu = resolved.backgroundElement + } + + return { + ...resolved, + _hasSelectedListItemText: hasSelectedListItemText, + } as Theme } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ @@ -288,11 +339,13 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs // Text colors text: fg, textMuted, + selectedListItemText: bg, // Background colors background: bg, backgroundPanel: grays[2], backgroundElement: grays[3], + backgroundMenu: grays[3], // Border colors borderSubtle: grays[6], diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 6bb59d6c7..96ef982d7 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -38,7 +38,7 @@ export function DialogAlert(props: DialogAlertProps) { dialog.clear() }} > - <text fg={theme.background}>ok</text> + <text fg={theme.selectedListItemText}>ok</text> </box> </box> </box> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index dd5b238b1..9d0e7d2c7 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -53,7 +53,9 @@ export function DialogConfirm(props: DialogConfirmProps) { dialog.clear() }} > - <text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text> + <text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}> + {Locale.titlecase(key)} + </text> </box> )} </For> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index f522fca9e..db9648f2c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -28,7 +28,7 @@ export function DialogHelp() { </box> <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}> <box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}> - <text fg={theme.background}>ok</text> + <text fg={theme.selectedListItemText}>ok</text> </box> </box> </box> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index b33641ecd..987bbd0b9 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,5 +1,5 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" -import { useTheme } from "@tui/context/theme" +import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js" import { createStore } from "solid-js/store" @@ -262,32 +262,29 @@ function Option(props: { onMouseOver?: () => void }) { const { theme } = useTheme() + const fg = selectedForeground(theme) return ( <> <Show when={props.current}> - <text - flexShrink={0} - fg={props.active ? theme.background : props.current ? theme.primary : theme.text} - marginRight={0.5} - > - ◆ + <text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0.5}> + ● </text> </Show> <text flexGrow={1} - fg={props.active ? theme.background : props.current ? theme.primary : theme.text} + fg={props.active ? fg : props.current ? theme.primary : theme.text} attributes={props.active ? TextAttributes.BOLD : undefined} overflow="hidden" wrapMode="none" paddingLeft={3} > {Locale.truncate(props.title, 62)} - <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> + <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span> </text> <Show when={props.footer}> <box flexShrink={0}> - <text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text> + <text fg={props.active ? fg : theme.textMuted}>{props.footer}</text> </box> </Show> </> diff --git a/packages/web/public/theme.json b/packages/web/public/theme.json index b3e97f7ca..7c8077634 100644 --- a/packages/web/public/theme.json +++ b/packages/web/public/theme.json @@ -46,6 +46,7 @@ "info": { "$ref": "#/definitions/colorValue" }, "text": { "$ref": "#/definitions/colorValue" }, "textMuted": { "$ref": "#/definitions/colorValue" }, + "selectedListItemText": { "$ref": "#/definitions/colorValue" }, "background": { "$ref": "#/definitions/colorValue" }, "backgroundPanel": { "$ref": "#/definitions/colorValue" }, "backgroundElement": { "$ref": "#/definitions/colorValue" }, |
