diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 22cddd98b50..ebfee4df2e6 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -74,6 +74,7 @@ import { useLocation } from "../../context/location" import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows" import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" +import type { ComponentTheme } from "../../theme/v2/component" addDefaultParsers(parsers.parsers) @@ -98,6 +99,11 @@ function use() { return ctx } +function offsetBackground(themeV2: ComponentTheme, mode: "light" | "dark") { + if (mode === "light") return themeV2.increase(themeV2.background.surface.offset(), 1) + return themeV2.decrease(themeV2.background.surface.offset(), 1) +} + export function Session() { const setEpilogue = useEpilogue() const clipboard = useClipboard() @@ -112,7 +118,7 @@ export function Session() { const paths = useTuiPaths() const configState = useConfig() const config = configState.data - const { themeV2 } = useTheme() + const { themeV2, mode } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) const messages = () => data.session.message.list(route.sessionID) @@ -891,7 +897,7 @@ export function Session() { paddingLeft: 1, visible: showScrollbar(), trackOptions: { - backgroundColor: themeV2.background.action("focused"), + backgroundColor: offsetBackground(themeV2, mode()), foregroundColor: themeV2.border(), }, }} @@ -1133,7 +1139,7 @@ function SessionReasoningGroupView(props: { message: (messageID: string) => SessionMessageInfo | undefined }) { const ctx = use() - const { themeV2, syntax } = useTheme() + const { themeV2, syntax, mode } = useTheme() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) @@ -1222,7 +1228,7 @@ function SessionReasoningGroupView(props: { }) { const ctx = use() - const { themeV2 } = useTheme().contextual("elevated") + const { themeV2, mode } = useTheme().contextual("elevated") const route = useRouteData("session") const client = useClient() const toast = useToast() @@ -1538,7 +1544,7 @@ function RevertMessage(props: { paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={hover() ? themeV2.background.action("focused") : themeV2.background()} + backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()} > {props.count} message{props.count === 1 ? "" : "s"} reverted @@ -1607,7 +1613,7 @@ function UserMessage(props: { message: SessionMessageUser }) { const data = useData() const local = useLocal() const files = createMemo(() => props.message.files ?? []) - const { themeV2 } = useTheme().contextual("elevated") + const { themeV2, mode } = useTheme().contextual("elevated") const [hover, setHover] = createSignal(false) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) const queued = createMemo( @@ -1644,7 +1650,7 @@ function UserMessage(props: { message: SessionMessageUser }) { paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={hover() ? themeV2.background.action("focused") : themeV2.background()} + backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()} flexShrink={0} > {props.message.text} @@ -1664,7 +1670,7 @@ function UserMessage(props: { message: SessionMessageUser }) { > {` ${label} `} - + {" "} {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} @@ -1852,7 +1858,7 @@ function ReasoningPart(props: { part: SessionMessageAssistantReasoning message: SessionMessageAssistant }) { - const { themeV2, syntax } = useTheme() + const { themeV2, syntax, mode } = useTheme() const ctx = use() // Collapsed by default in hide mode: a single line throughout, so the // layout never shifts. Click to open the full markdown block, click to close. @@ -1880,7 +1886,7 @@ function ReasoningPart(props: { @@ -1898,7 +1904,7 @@ function ReasoningPart(props: { props.output?.trim() ?? "") const args = createMemo(() => JSON.stringify(props.input, null, 2)) const [expanded, setExpanded] = createSignal(false) @@ -2093,7 +2099,7 @@ function GenericTool(props: ToolProps) { 0}> - Input + Input ( - Output + Output @@ -2299,9 +2305,9 @@ function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.El } function StatusBadge(props: { children: string }) { - const { themeV2 } = useTheme() + const { themeV2, mode } = useTheme() return ( - + {" "} {props.children}{" "} @@ -2316,7 +2322,7 @@ function BlockTool(props: { part?: SessionMessageAssistantTool spinner?: boolean }) { - const { themeV2 } = useTheme().contextual("elevated") + const { themeV2, mode } = useTheme().contextual("elevated") const ctx = use() const data = useData() const renderer = useRenderer() @@ -2335,7 +2341,7 @@ function BlockTool(props: { paddingLeft={2} gap={1} backgroundColor={ - hover() ? themeV2.background.action("focused") : themeV2.background() + hover() ? offsetBackground(themeV2, mode()) : themeV2.background() } customBorderChars={SplitBorder.customBorderChars} borderColor={themeV2.background()} diff --git a/packages/tui/src/theme/v2/component.ts b/packages/tui/src/theme/v2/component.ts index 10aa45a7baa..f6ffd765c7d 100644 --- a/packages/tui/src/theme/v2/component.ts +++ b/packages/tui/src/theme/v2/component.ts @@ -78,8 +78,8 @@ export function createComponentTheme(current: Accessor) { return { hue, - increase: (color: RGBA, amount = 1) => shiftHue(current(), color, amount), - decrease: (color: RGBA, amount = 1) => shiftHue(current(), color, -amount), + increase: (color: RGBA, amount = 1) => current().increase(color, amount), + decrease: (color: RGBA, amount = 1) => current().decrease(color, amount), text, background, border: () => current().border.default, @@ -123,17 +123,6 @@ export function createComponentTheme(current: Accessor) { } } -function shiftHue(theme: ResolvedThemeView, color: RGBA, amount: number) { - const colors = Object.values(theme.hue).flatMap((scale) => - HueStep.literals.map((step, index) => ({ color: scale[step], index, scale })), - ) - const match = colors.find((entry) => entry.color === color) ?? colors.find((entry) => entry.color.equals(color)) - if (!match) return color - const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0 - const index = Math.max(0, Math.min(HueStep.literals.length - 1, match.index + offset)) - return match.scale[HueStep.literals[index]] -} - function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) { const action = (variant: ActionVariant) => (states: ActionState | "default" | ActionStates = "default") => { if (typeof states === "string") return get(variant, states) diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts index 7a9d3e09839..7c2ebde3230 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/tui/src/theme/v2/resolve.ts @@ -70,14 +70,15 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme { function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme { const hue = resolveHue(definition.hue) + const hueSteps = compileHueSteps(hue) const base = tokens(definition) - const resolved = resolveView(base, hue) + const resolved = resolveView(base, hue, hueSteps) const contexts = Object.fromEntries( Object.entries(definition) .filter(([key]) => key.startsWith("@context:")) .map(([key, override]) => { const contextual = contextualize(base, override as ThemeTokensDefinition) - return [key, resolveView(contextual, hue)] + return [key, resolveView(contextual, hue, hueSteps)] }), ) @@ -138,9 +139,36 @@ function contextualActions( ) } -function resolveView(definition: ThemeTokensDefinition, hue: ResolvedThemeView["hue"]): ResolvedThemeView { +function resolveView( + definition: ThemeTokensDefinition, + hue: ResolvedThemeView["hue"], + hueSteps: Pick, +): ResolvedThemeView { const source: Record = { hue, ...definition } - return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue } + return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps } +} + +function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick { + const index = new Map( + Object.values(hue).flatMap((scale) => + HueStep.literals.map((step, position) => [colorKey(scale[step]), { position, scale }] as const), + ), + ) + const shift = (color: RGBA, amount: number) => { + const match = index.get(colorKey(color)) + if (!match) return color + const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0 + const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset)) + return match.scale[HueStep.literals[position]] + } + return { + increase: (color, amount = 1) => shift(color, amount), + decrease: (color, amount = 1) => shift(color, -amount), + } +} + +function colorKey(color: RGBA) { + return color.toInts().join(":") } function resolveHue(definition: HueDefinition) { diff --git a/packages/tui/src/theme/v2/types.ts b/packages/tui/src/theme/v2/types.ts index de6ca088c9d..b22c01c31cc 100644 --- a/packages/tui/src/theme/v2/types.ts +++ b/packages/tui/src/theme/v2/types.ts @@ -21,6 +21,8 @@ export type FormfieldColor = Readonly> export type ResolvedThemeView = { readonly hue: Hue + readonly increase: (color: RGBA, amount?: number) => RGBA + readonly decrease: (color: RGBA, amount?: number) => RGBA readonly text: { readonly default: RGBA readonly subdued: RGBA diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 5bd9bf6325c..8f69addb446 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -64,4 +64,5 @@ test("provides reactive property, variant, state, and context accessors", () => setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default) + expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700]) }) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 0d8b1dd30d3..c5a31a9d188 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -15,6 +15,11 @@ test("resolves independent definitions and hue aliases", () => { expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue) expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue) expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray) + expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) + expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100]) + expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe( + lightTheme.hue.red[200], + ) expect(lightTheme.text.default).toBeInstanceOf(RGBA) expect(darkTheme.background.default).toBeInstanceOf(RGBA) expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])