From 1d725333582017c5647a7266f7a1a3463ecf823a Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 23 Jan 2026 09:24:14 -0700 Subject: [PATCH] feat(tui): add theme-configurable text selection colors Add selection and selectionForeground properties to the theme system to fix poor contrast when selecting text in themes like orng. Changes: - Add selection/selectionForeground to ThemeColors type - Add smart fallbacks: selection defaults to primary color, foreground auto-calculated based on luminance for contrast - Update orng.json with explicit high-contrast selection colors - Pass selectionBg/selectionFg props to all text-rendering components in session view (code blocks, text, diff, user messages, tool output) Themes without explicit selection colors get sensible defaults. Theme authors can customize via selection/selectionForeground properties. --- .../src/cli/cmd/tui/component/todo-item.tsx | 4 ++ .../src/cli/cmd/tui/context/theme.tsx | 31 +++++++++++- .../src/cli/cmd/tui/context/theme/orng.json | 8 ++++ .../src/cli/cmd/tui/routes/session/index.tsx | 48 +++++++++++-------- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx b/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx index b54cc4633412..d47d7cbe898f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/todo-item.tsx @@ -15,6 +15,8 @@ export function TodoItem(props: TodoItemProps) { style={{ fg: props.status === "in_progress" ? theme.warning : theme.textMuted, }} + selectionBg={theme.selection} + selectionFg={theme.selectionForeground} > [{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "} @@ -24,6 +26,8 @@ export function TodoItem(props: TodoItemProps) { style={{ fg: props.status === "in_progress" ? theme.warning : theme.textMuted, }} + selectionBg={theme.selection} + selectionFg={theme.selectionForeground} > {props.content} diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7cde1b9648ee..7b635cf0fb93 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -54,6 +54,8 @@ type ThemeColors = { text: RGBA textMuted: RGBA selectedListItemText: RGBA + selection: RGBA + selectionForeground: RGBA background: RGBA backgroundPanel: RGBA backgroundElement: RGBA @@ -131,9 +133,11 @@ type ColorValue = HexColor | RefName | Variant | RGBA type ThemeJson = { $schema?: string defs?: Record - theme: Omit, "selectedListItemText" | "backgroundMenu"> & { + theme: Omit, "selectedListItemText" | "backgroundMenu" | "selection" | "selectionForeground"> & { selectedListItemText?: ColorValue backgroundMenu?: ColorValue + selection?: ColorValue + selectionForeground?: ColorValue thinkingOpacity?: number } } @@ -197,9 +201,10 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { return resolveColor(c[mode]) } + const optionalKeys = ["selectedListItemText", "backgroundMenu", "selection", "selectionForeground", "thinkingOpacity"] const resolved = Object.fromEntries( Object.entries(theme.theme) - .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") + .filter(([key]) => !optionalKeys.includes(key)) .map(([key, value]) => { return [key, resolveColor(value as ColorValue)] }), @@ -222,6 +227,24 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { resolved.backgroundMenu = resolved.backgroundElement } + // Handle selection colors - optional with smart fallbacks for good contrast + if (theme.theme.selection !== undefined) { + resolved.selection = resolveColor(theme.theme.selection) + } else { + // Default selection background to primary color for visibility + resolved.selection = resolved.primary + } + + if (theme.theme.selectionForeground !== undefined) { + resolved.selectionForeground = resolveColor(theme.theme.selectionForeground) + } else { + // Calculate contrasting foreground based on selection background luminance + const sel = resolved.selection! + const luminance = 0.299 * sel.r + 0.587 * sel.g + 0.114 * sel.b + // Use white text on dark backgrounds, black text on light backgrounds + resolved.selectionForeground = luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255) + } + // Handle thinkingOpacity - optional with default of 0.6 const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 @@ -479,6 +502,10 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs textMuted, selectedListItemText: bg, + // Selection colors - use cyan background with contrasting foreground + selection: ansiColors.cyan, + selectionForeground: isDark ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255), + // Background colors background: bg, backgroundPanel: grays[2], diff --git a/packages/opencode/src/cli/cmd/tui/context/theme/orng.json b/packages/opencode/src/cli/cmd/tui/context/theme/orng.json index 1fc602f2c8b8..579cff71b15d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme/orng.json +++ b/packages/opencode/src/cli/cmd/tui/context/theme/orng.json @@ -81,6 +81,14 @@ "dark": "#0a0a0a", "light": "#ffffff" }, + "selection": { + "dark": "darkOrange", + "light": "lightStep10" + }, + "selectionForeground": { + "dark": "darkStep1", + "light": "#ffffff" + }, "background": { "dark": "darkStep1", "light": "lightStep1" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d36a7d209940..e3ad60e12a21 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -999,8 +999,8 @@ export function Session() { paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} > - {revert()!.reverted.length} message reverted - + {revert()!.reverted.length} message reverted + {keybind.print("messages_redo")} or /redo to restore @@ -1008,7 +1008,7 @@ export function Session() { {(file) => ( - + {file.filename} 0}> +{file.additions} @@ -1164,7 +1164,7 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()?.text} + {text()?.text} @@ -1175,7 +1175,7 @@ function UserMessage(props: { return theme.secondary }) return ( - + {MIME_BADGE[file.mime] ?? file.mime} {file.filename} @@ -1262,13 +1262,13 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} > - {props.message.error?.data.message} + {props.message.error?.data.message} - + @@ -1347,6 +1349,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess streaming={true} content={props.part.text.trim()} conceal={ctx.conceal()} + selectionBg={theme.selection} + selectionFg={theme.selectionForeground} /> @@ -1358,6 +1362,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess content={props.part.text.trim()} conceal={ctx.conceal()} fg={theme.text} + selectionBg={theme.selection} + selectionFg={theme.selectionForeground} /> @@ -1474,7 +1480,7 @@ function GenericTool(props: ToolProps) { function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { const { theme } = useTheme() return ( - + ~ {props.fallback}} when={props.when}> {props.icon} {props.children} @@ -1543,13 +1549,13 @@ function InlineTool(props: { } }} > - + ~ {props.pending}} when={props.complete}> {props.icon} {props.children} - {error()} + {error()} ) @@ -1578,12 +1584,12 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = props.onClick?.() }} > - + {props.title} {props.children} - {error()} + {error()} ) @@ -1635,10 +1641,10 @@ function Bash(props: ToolProps) { onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined} > - $ {props.input.command} - {limited()} + $ {props.input.command} + {limited()} - {expanded() ? "Click to collapse" : "Click to expand"} + {expanded() ? "Click to collapse" : "Click to expand"} @@ -1675,12 +1681,14 @@ function Write(props: ToolProps) { filetype={filetype(props.input.filePath!)} syntaxStyle={syntax()} content={code()} + selectionBg={theme.selection} + selectionFg={theme.selectionForeground} /> {(diagnostic) => ( - + Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} )} @@ -1806,17 +1814,17 @@ function Task(props: ToolProps) { part={props.part} > - + {props.input.description} ({props.metadata.summary?.length} toolcalls) - + └ {Locale.titlecase(current()!.tool)}{" "} {current()!.state.status === "completed" ? current()!.state.title : ""} - + {keybind.print("session_child_cycle")} view subagents @@ -1888,7 +1896,7 @@ function Edit(props: ToolProps) { {(diagnostic) => ( - + Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "} {diagnostic.message}