refactor(tui): migrate selection views to V2 theme (#38001)

This commit is contained in:
James Long 2026-07-22 16:19:00 -04:00 committed by GitHub
commit 88f572cfce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 43 deletions

View file

@ -20,7 +20,7 @@ export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const data = useData() const data = useData()
const { theme } = useTheme() const { themeV2, mode } = useTheme().contextual("elevated")
const client = useClient() const client = useClient()
const local = useLocal() const local = useLocal()
const toast = useToast() const toast = useToast()
@ -109,12 +109,13 @@ export function DialogSessionList() {
value: session.id, value: session.id,
category, category,
footer, footer,
bg: deleting ? theme.error : undefined, bg: deleting ? themeV2.background.action.destructive.focused : undefined,
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner /> ? () => <Spinner />
: slot === undefined : slot === undefined
? undefined ? undefined
: () => <text fg={theme.accent}>{slot}</text>, : () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
} }
} }
@ -142,12 +143,14 @@ export function DialogSessionList() {
}} }}
emptyView={ emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No sessions available</text> <text fg={themeV2.text.subdued}>No sessions available</text>
</box> </box>
} }
noMatchView={ noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? theme.error : theme.textMuted}>{searchState().message}</text> <text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{searchState().message}
</text>
</box> </box>
} }
onMove={() => setToDelete(undefined)} onMove={() => setToDelete(undefined)}

View file

@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog() const dialog = useDialog()
const stash = usePromptStash() const stash = usePromptStash()
const { theme } = useTheme() const { themeV2 } = useTheme().contextual("elevated")
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>() const [toDelete, setToDelete] = createSignal<number>()
@ -45,7 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
title: isDeleting title: isDeleting
? `Press ${shortcuts.get("stash.delete")} again to confirm` ? `Press ${shortcuts.get("stash.delete")} again to confirm`
: getStashPreview(entry.prompt.text), : getStashPreview(entry.prompt.text),
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
value: index, value: index,
description: getRelativeTime(entry.timestamp), description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined, footer: lineCount > 1 ? `~${lineCount} lines` : undefined,

View file

@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime" import { useTuiPaths } from "../../context/runtime"
import { useConfig } from "../../config" import { useConfig } from "../../config"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
@ -57,7 +57,7 @@ export function Autocomplete(props: {
const data = useData() const data = useData()
const keymap = Keymap.use() const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands() const keymapCommands = Keymap.useCommands()
const { theme } = useTheme() const { themeV2 } = useTheme().contextual("overlay")
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
const config = useConfig().data const config = useConfig().data
@ -698,11 +698,11 @@ export function Autocomplete(props: {
width={position().width} width={position().width}
zIndex={100} zIndex={100}
{...SplitBorder} {...SplitBorder}
borderColor={theme.border} borderColor={themeV2.border.default}
> >
<scrollbox <scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)} ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.backgroundMenu} backgroundColor={themeV2.background.default}
height={height()} height={height()}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()} scrollAcceleration={scrollAcceleration()}
@ -711,7 +711,9 @@ export function Autocomplete(props: {
each={options()} each={options()}
fallback={ fallback={
<box paddingLeft={1} paddingRight={1}> <box paddingLeft={1} paddingRight={1}>
<text fg={emptyError() ? theme.error : theme.textMuted}>{emptyMessage()}</text> <text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{emptyMessage()}
</text>
</box> </box>
} }
> >
@ -719,7 +721,7 @@ export function Autocomplete(props: {
<box <box
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined} backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
flexDirection="row" flexDirection="row"
onMouseMove={() => { onMouseMove={() => {
setStore("input", "mouse") setStore("input", "mouse")
@ -734,11 +736,17 @@ export function Autocomplete(props: {
}} }}
onMouseUp={() => select()} onMouseUp={() => select()}
> >
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}> <text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
flexShrink={0}
>
{option().display} {option().display}
</text> </text>
<Show when={option().description}> <Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none"> <text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()} {" " + option().description?.trimStart()}
</text> </text>
</Show> </Show>

View file

@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap" import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, selectedForeground } from "../context/theme" import { useTheme } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@ -74,6 +74,7 @@ export interface DialogSelectOption<T = any> {
categoryView?: JSX.Element categoryView?: JSX.Element
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
fg?: RGBA
gutter?: () => JSX.Element gutter?: () => JSX.Element
margin?: JSX.Element margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext) => void
@ -91,7 +92,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
type VisibleAction = (Action & { label: string }) | FooterHint type VisibleAction = (Action & { label: string }) | FooterHint
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { themeV2, mode } = useTheme().contextual("elevated")
const config = useConfig().data const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@ -517,29 +518,44 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!isActionItem(action.item)) if (!isActionItem(action.item))
return ( return (
<text> <text>
<span style={{ fg: theme.text }}> <span style={{ fg: themeV2.text.default }}>
<b>{action.item.title}</b>{" "} <b>{action.item.title}</b>{" "}
</span> </span>
<span style={{ fg: theme.textMuted }}>{action.item.label}</span> <span style={{ fg: themeV2.text.subdued }}>{action.item.label}</span>
</text> </text>
) )
const item = action.item const item = action.item
const active = createMemo(() => isActionFocused(item)) const active = createMemo(() => isActionFocused(item))
const disabled = createMemo(() => isActionDisabled(item)) const disabled = createMemo(() => isActionDisabled(item))
const fg = selectedForeground(theme)
return ( return (
<box <box
flexDirection="row" flexDirection="row"
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={active() ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => trigger(item)} onMouseUp={() => trigger(item)}
> >
<text <text
fg={disabled() ? theme.textMuted : active() ? fg : theme.text} fg={
disabled()
? themeV2.text.action.primary.disabled
: active()
? themeV2.text.action.primary.focused
: themeV2.text.default
}
attributes={active() ? TextAttributes.BOLD : undefined} attributes={active() ? TextAttributes.BOLD : undefined}
> >
{item.title} {item.title}
</text> </text>
<text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text> <text
fg={
disabled()
? themeV2.text.action.primary.disabled
: active()
? themeV2.text.action.primary.focused
: themeV2.text.subdued
}
>
{" " + item.label}
</text>
</box> </box>
) )
} }
@ -549,11 +565,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box paddingLeft={4} paddingRight={4}> <box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
{props.titleView ?? ( {props.titleView ?? (
<text fg={theme.text} attributes={TextAttributes.BOLD}> <text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
{props.title} {props.title}
</text> </text>
)} )}
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc esc
</text> </text>
</box> </box>
@ -567,9 +583,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e) props.onFilter?.(e)
}) })
}} }}
focusedBackgroundColor={theme.backgroundPanel} focusedBackgroundColor={themeV2.background.formfield.focused}
cursorColor={theme.primary} cursorColor={themeV2.text.formfield.focused}
focusedTextColor={theme.textMuted} focusedTextColor={themeV2.text.formfield.focused}
ref={(r) => { ref={(r) => {
input = r input = r
input.traits = { status: "FILTER" } input.traits = { status: "FILTER" }
@ -580,7 +596,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}, 1) }, 1)
}} }}
placeholder={props.placeholder ?? "Search"} placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted} placeholderColor={themeV2.text.subdued}
/> />
</box> </box>
</Show> </Show>
@ -594,14 +610,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
fallback={ fallback={
props.emptyView ?? ( props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No items available</text> <text fg={themeV2.text.subdued}>No items available</text>
</box> </box>
) )
} }
> >
{props.noMatchView ?? ( {props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text> <text fg={themeV2.text.subdued}>No results found</text>
</box> </box>
)} )}
</Show> </Show>
@ -623,7 +639,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show <Show
when={options[0]?.categoryView} when={options[0]?.categoryView}
fallback={ fallback={
<text fg={theme.accent} attributes={TextAttributes.BOLD}> <text
fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}
attributes={TextAttributes.BOLD}
>
{category} {category}
</text> </text>
} }
@ -672,8 +691,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
backgroundColor={ backgroundColor={
active() active()
? actionFocused() ? actionFocused()
? theme.backgroundElement ? themeV2.background.surface.overlay
: (option.bg ?? theme.primary) : (option.bg ?? themeV2.background.action.primary.focused)
: RGBA.fromInts(0, 0, 0, 0) : RGBA.fromInts(0, 0, 0, 0)
} }
> >
@ -692,6 +711,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
active={active()} active={active()}
current={current()} current={current()}
muted={actionFocused()} muted={actionFocused()}
activeColor={option.fg}
gutter={option.gutter} gutter={option.gutter}
/> />
</box> </box>
@ -699,7 +719,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{(detail) => ( {(detail) => (
<box paddingLeft={3} paddingRight={3}> <box paddingLeft={3} paddingRight={3}>
<text <text
fg={option.detailsColor ?? theme.textMuted} fg={option.detailsColor ?? themeV2.text.subdued}
wrapMode={option.detailsWrap ? "word" : "none"} wrapMode={option.detailsWrap ? "word" : "none"}
> >
{option.detailsWrap {option.detailsWrap
@ -745,15 +765,15 @@ function Option(props: {
titleWidth?: number titleWidth?: number
truncateTitle?: boolean | "left" truncateTitle?: boolean | "left"
gutter?: () => JSX.Element gutter?: () => JSX.Element
activeColor?: RGBA
onMouseOver?: () => void onMouseOver?: () => void
}) { }) {
const { theme } = useTheme() const { themeV2 } = useTheme().contextual("elevated")
const fg = selectedForeground(theme)
const text = createMemo(() => { const text = createMemo(() => {
if (props.active && !props.muted) return fg if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused
if (props.muted && (props.active || props.current)) return theme.textMuted if (props.muted && (props.active || props.current)) return themeV2.text.subdued
if (props.current) return theme.primary if (props.current) return themeV2.text.formfield.selected
return theme.text return themeV2.text.default
}) })
return ( return (
@ -783,12 +803,14 @@ function Option(props: {
? Locale.truncateLeft(props.title, props.titleWidth ?? 61) ? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))} : Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}> <Show when={props.description}>
<span style={{ fg: props.active && !props.muted ? fg : theme.textMuted }}> {props.description}</span> <span style={{ fg: props.active && !props.muted ? text() : themeV2.text.subdued }}>
{" " + props.description}
</span>
</Show> </Show>
</text> </text>
<Show when={props.footer}> <Show when={props.footer}>
<box flexShrink={0}> <box flexShrink={0}>
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text> <text fg={props.active && !props.muted ? text() : themeV2.text.subdued}>{props.footer}</text>
</box> </box>
</Show> </Show>
</> </>