Skip to content

Commit 7d11986

Browse files
ariane-emoryknanao
andauthored
feature: optional selectedListItemText element in themes and luminance-based fallback to solve 4369 (#4572)
Co-authored-by: knanao <nao.7ken@gmail.com> Co-authored-by: knanao <k@knanao.com>
1 parent d75d90c commit 7d11986

File tree

8 files changed

+90
-30
lines changed

8 files changed

+90
-30
lines changed

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
55
import { createStore } from "solid-js/store"
66
import { useSDK } from "@tui/context/sdk"
77
import { useSync } from "@tui/context/sync"
8-
import { useTheme } from "@tui/context/theme"
8+
import { useTheme, selectedForeground } from "@tui/context/theme"
99
import { SplitBorder } from "@tui/component/border"
1010
import { useCommandDialog } from "@tui/component/dialog-command"
1111
import { Locale } from "@/util/locale"
@@ -455,7 +455,7 @@ export function Autocomplete(props: {
455455
{...SplitBorder}
456456
borderColor={theme.border}
457457
>
458-
<box backgroundColor={theme.backgroundElement} height={height()}>
458+
<box backgroundColor={theme.backgroundMenu} height={height()}>
459459
<For
460460
each={options()}
461461
fallback={
@@ -471,11 +471,11 @@ export function Autocomplete(props: {
471471
backgroundColor={index() === store.selected ? theme.primary : undefined}
472472
flexDirection="row"
473473
>
474-
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
474+
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
475475
{option.display}
476476
</text>
477477
<Show when={option.description}>
478-
<text fg={index() === store.selected ? theme.background : theme.textMuted} wrapMode="none">
478+
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
479479
{option.description}
480480
</text>
481481
</Show>

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -791,10 +791,17 @@ export function Prompt(props: PromptProps) {
791791
height={1}
792792
border={["bottom"]}
793793
borderColor={theme.backgroundElement}
794-
customBorderChars={{
795-
...EmptyBorder,
796-
horizontal: "▀",
797-
}}
794+
customBorderChars={
795+
theme.background.a != 0
796+
? {
797+
...EmptyBorder,
798+
horizontal: "▀",
799+
}
800+
: {
801+
...EmptyBorder,
802+
horizontal: " ",
803+
}
804+
}
798805
/>
799806
</box>
800807
<box flexDirection="row" justifyContent="space-between">

packages/opencode/src/cli/cmd/tui/context/theme.tsx

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { createStore, produce } from "solid-js/store"
3333
import { Global } from "@/global"
3434
import { Filesystem } from "@/util/filesystem"
3535

36-
type Theme = {
36+
type ThemeColors = {
3737
primary: RGBA
3838
secondary: RGBA
3939
accent: RGBA
@@ -43,9 +43,11 @@ type Theme = {
4343
info: RGBA
4444
text: RGBA
4545
textMuted: RGBA
46+
selectedListItemText: RGBA
4647
background: RGBA
4748
backgroundPanel: RGBA
4849
backgroundElement: RGBA
50+
backgroundMenu: RGBA
4951
border: RGBA
5052
borderActive: RGBA
5153
borderSubtle: RGBA
@@ -86,6 +88,27 @@ type Theme = {
8688
syntaxPunctuation: RGBA
8789
}
8890

91+
type Theme = ThemeColors & {
92+
_hasSelectedListItemText: boolean
93+
}
94+
95+
export function selectedForeground(theme: Theme): RGBA {
96+
// If theme explicitly defines selectedListItemText, use it
97+
if (theme._hasSelectedListItemText) {
98+
return theme.selectedListItemText
99+
}
100+
101+
// For transparent backgrounds, calculate contrast based on primary color
102+
if (theme.background.a === 0) {
103+
const { r, g, b } = theme.primary
104+
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
105+
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
106+
}
107+
108+
// Fall back to background color
109+
return theme.background
110+
}
111+
89112
type HexColor = `#${string}`
90113
type RefName = string
91114
type Variant = {
@@ -96,7 +119,10 @@ type ColorValue = HexColor | RefName | Variant | RGBA
96119
type ThemeJson = {
97120
$schema?: string
98121
defs?: Record<string, HexColor | RefName>
99-
theme: Record<keyof Theme, ColorValue>
122+
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
123+
selectedListItemText?: ColorValue
124+
backgroundMenu?: ColorValue
125+
}
100126
}
101127

102128
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
@@ -137,19 +163,44 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
137163

138164
if (defs[c]) {
139165
return resolveColor(defs[c])
140-
} else if (theme.theme[c as keyof Theme]) {
141-
return resolveColor(theme.theme[c as keyof Theme])
166+
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
167+
return resolveColor(theme.theme[c as keyof ThemeColors]!)
142168
} else {
143169
throw new Error(`Color reference "${c}" not found in defs or theme`)
144170
}
145171
}
146172
return resolveColor(c[mode])
147173
}
148-
return Object.fromEntries(
149-
Object.entries(theme.theme).map(([key, value]) => {
150-
return [key, resolveColor(value)]
151-
}),
152-
) as Theme
174+
175+
const resolved = Object.fromEntries(
176+
Object.entries(theme.theme)
177+
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu")
178+
.map(([key, value]) => {
179+
return [key, resolveColor(value)]
180+
}),
181+
) as Partial<ThemeColors>
182+
183+
// Handle selectedListItemText separately since it's optional
184+
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
185+
if (hasSelectedListItemText) {
186+
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
187+
} else {
188+
// Backward compatibility: if selectedListItemText is not defined, use background color
189+
// This preserves the current behavior for all existing themes
190+
resolved.selectedListItemText = resolved.background
191+
}
192+
193+
// Handle backgroundMenu - optional with fallback to backgroundElement
194+
if (theme.theme.backgroundMenu !== undefined) {
195+
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
196+
} else {
197+
resolved.backgroundMenu = resolved.backgroundElement
198+
}
199+
200+
return {
201+
...resolved,
202+
_hasSelectedListItemText: hasSelectedListItemText,
203+
} as Theme
153204
}
154205

155206
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
@@ -288,11 +339,13 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
288339
// Text colors
289340
text: fg,
290341
textMuted,
342+
selectedListItemText: bg,
291343

292344
// Background colors
293345
background: bg,
294346
backgroundPanel: grays[2],
295347
backgroundElement: grays[3],
348+
backgroundMenu: grays[3],
296349

297350
// Border colors
298351
borderSubtle: grays[6],

packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function DialogAlert(props: DialogAlertProps) {
3838
dialog.clear()
3939
}}
4040
>
41-
<text fg={theme.background}>ok</text>
41+
<text fg={theme.selectedListItemText}>ok</text>
4242
</box>
4343
</box>
4444
</box>

packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export function DialogConfirm(props: DialogConfirmProps) {
5353
dialog.clear()
5454
}}
5555
>
56-
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
56+
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
57+
{Locale.titlecase(key)}
58+
</text>
5759
</box>
5860
)}
5961
</For>

packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function DialogHelp() {
2828
</box>
2929
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
3030
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
31-
<text fg={theme.background}>ok</text>
31+
<text fg={theme.selectedListItemText}>ok</text>
3232
</box>
3333
</box>
3434
</box>

packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
2-
import { useTheme } from "@tui/context/theme"
2+
import { useTheme, selectedForeground } from "@tui/context/theme"
33
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
44
import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js"
55
import { createStore } from "solid-js/store"
@@ -262,32 +262,29 @@ function Option(props: {
262262
onMouseOver?: () => void
263263
}) {
264264
const { theme } = useTheme()
265+
const fg = selectedForeground(theme)
265266

266267
return (
267268
<>
268269
<Show when={props.current}>
269-
<text
270-
flexShrink={0}
271-
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
272-
marginRight={0.5}
273-
>
274-
270+
<text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0.5}>
271+
275272
</text>
276273
</Show>
277274
<text
278275
flexGrow={1}
279-
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
276+
fg={props.active ? fg : props.current ? theme.primary : theme.text}
280277
attributes={props.active ? TextAttributes.BOLD : undefined}
281278
overflow="hidden"
282279
wrapMode="none"
283280
paddingLeft={3}
284281
>
285282
{Locale.truncate(props.title, 62)}
286-
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
283+
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
287284
</text>
288285
<Show when={props.footer}>
289286
<box flexShrink={0}>
290-
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
287+
<text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
291288
</box>
292289
</Show>
293290
</>

packages/web/public/theme.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"info": { "$ref": "#/definitions/colorValue" },
4747
"text": { "$ref": "#/definitions/colorValue" },
4848
"textMuted": { "$ref": "#/definitions/colorValue" },
49+
"selectedListItemText": { "$ref": "#/definitions/colorValue" },
4950
"background": { "$ref": "#/definitions/colorValue" },
5051
"backgroundPanel": { "$ref": "#/definitions/colorValue" },
5152
"backgroundElement": { "$ref": "#/definitions/colorValue" },

0 commit comments

Comments
 (0)