diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 4c286eaa438d..44639ab2898e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -188,7 +188,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() - const { themeV2, syntax, mode } = useTheme() + const { themeV2, syntax } = useTheme() const animationsEnabled = createMemo(() => config.animations ?? true) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) @@ -1379,11 +1379,7 @@ export function Prompt(props: PromptProps) { }) const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3))) - const promptBg = createMemo(() => - mode() === "light" - ? themeV2.increase(themeV2.background.surface.offset(), 1) - : themeV2.decrease(themeV2.background.surface.offset(), 1), - ) + const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset())) return ( <> diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 7efc29b3aad6..836277261c8d 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -297,18 +297,19 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted)) return result }) - const themeV2 = createComponentTheme(valuesV2) + const mode = () => store.mode + const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { elevated: createComponentTheme(() => { const theme = valuesV2().contexts["@context:elevated"] if (!theme) throw new Error("Theme context is not defined: elevated") return theme - }), + }, mode), overlay: createComponentTheme(() => { const theme = valuesV2().contexts["@context:overlay"] if (!theme) throw new Error("Theme context is not defined: overlay") return theme - }), + }, mode), } createEffect(() => renderer.setBackgroundColor(values().background)) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c1d9ca74420c..13370ae17ef5 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -74,7 +74,6 @@ 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" import { stringWidth } from "../../util/string-width" addDefaultParsers(parsers.parsers) @@ -100,11 +99,6 @@ 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() @@ -119,7 +113,7 @@ export function Session() { const paths = useTuiPaths() const configState = useConfig() const config = configState.data - const { themeV2, mode } = useTheme() + const { themeV2 } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) const messages = () => data.session.message.list(route.sessionID) @@ -898,7 +892,7 @@ export function Session() { paddingLeft: 1, visible: showScrollbar(), trackOptions: { - backgroundColor: offsetBackground(themeV2, mode()), + backgroundColor: themeV2.raise(themeV2.background.surface.offset()), foregroundColor: themeV2.border(), }, }} @@ -1140,7 +1134,7 @@ function SessionReasoningGroupView(props: { message: (messageID: string) => SessionMessageInfo | undefined }) { const ctx = use() - const { themeV2, syntax, mode } = useTheme() + const { themeV2, syntax } = useTheme() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) @@ -1229,7 +1223,7 @@ function SessionReasoningGroupView(props: { }) { const ctx = use() - const { themeV2, mode } = useTheme().contextual("elevated") + const { themeV2 } = useTheme().contextual("elevated") const route = useRouteData("session") const client = useClient() const toast = useToast() @@ -1544,7 +1538,7 @@ function RevertMessage(props: { paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()} + backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()} > {props.count} message{props.count === 1 ? "" : "s"} reverted @@ -1613,7 +1607,7 @@ function UserMessage(props: { message: SessionMessageUser }) { const data = useData() const local = useLocal() const files = createMemo(() => props.message.files ?? []) - const { themeV2, mode } = useTheme().contextual("elevated") + const { themeV2 } = useTheme().contextual("elevated") const [hover, setHover] = createSignal(false) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) const queued = createMemo( @@ -1650,7 +1644,7 @@ function UserMessage(props: { message: SessionMessageUser }) { paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()} + backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()} flexShrink={0} > {props.message.text} @@ -1670,7 +1664,7 @@ function UserMessage(props: { message: SessionMessageUser }) { > {` ${label} `} - + {" "} {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} @@ -1858,7 +1852,7 @@ function ReasoningPart(props: { part: SessionMessageAssistantReasoning message: SessionMessageAssistant }) { - const { themeV2, syntax, mode } = useTheme() + const { themeV2, syntax } = 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. @@ -1886,7 +1880,7 @@ function ReasoningPart(props: { @@ -1904,7 +1898,7 @@ function ReasoningPart(props: { props.output?.trim() ?? "") const args = createMemo(() => JSON.stringify(props.input, null, 2)) const [expanded, setExpanded] = createSignal(false) @@ -2099,7 +2093,7 @@ function GenericTool(props: ToolProps) { 0}> - Input + Input ( - Output + Output @@ -2305,9 +2299,9 @@ function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.El } function StatusBadge(props: { children: string }) { - const { themeV2, mode } = useTheme() + const { themeV2 } = useTheme() return ( - + {" "} {props.children}{" "} @@ -2322,7 +2316,7 @@ function BlockTool(props: { part?: SessionMessageAssistantTool spinner?: boolean }) { - const { themeV2, mode } = useTheme().contextual("elevated") + const { themeV2 } = useTheme().contextual("elevated") const ctx = use() const data = useData() const renderer = useRenderer() @@ -2341,7 +2335,7 @@ function BlockTool(props: { paddingLeft={2} gap={1} backgroundColor={ - hover() ? offsetBackground(themeV2, mode()) : themeV2.background() + hover() ? themeV2.raise(themeV2.background()) : themeV2.background() } customBorderChars={SplitBorder.customBorderChars} borderColor={themeV2.background()} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a5b57daeca49..3b3ecf41af8e 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -516,7 +516,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( paddingLeft={2} paddingRight={3} paddingBottom={1} - backgroundColor={themeV2.background.action("focused")} + backgroundColor={themeV2.raise(themeV2.background())} justifyContent={narrow() ? "flex-start" : "space-between"} alignItems={narrow() ? "flex-start" : "center"} gap={1} @@ -693,7 +693,7 @@ function Prompt>(props: { paddingLeft={2} paddingRight={3} paddingBottom={1} - backgroundColor={themeV2.background.action("focused")} + backgroundColor={themeV2.raise(themeV2.background())} justifyContent={narrow() ? "flex-start" : "space-between"} alignItems={narrow() ? "flex-start" : "center"} > diff --git a/packages/tui/src/theme/v2/component.ts b/packages/tui/src/theme/v2/component.ts index 7487473bd272..3c065f4a2826 100644 --- a/packages/tui/src/theme/v2/component.ts +++ b/packages/tui/src/theme/v2/component.ts @@ -2,6 +2,7 @@ import type { RGBA } from "@opentui/core" import type { Accessor } from "solid-js" import type { ActionVariant, + Mode, ResolvedActionState, ResolvedThemeView, } from "./index" @@ -9,7 +10,7 @@ import { ActionState, HueStep } from "./schema" type StateFlags = Partial> -export function createComponentTheme(current: Accessor) { +export function createComponentTheme(current: Accessor, mode: Accessor) { const textAction = actions((variant, state) => current().text.action[variant][state]) const backgroundAction = actions((variant, state) => current().background.action[variant][state]) const textFormfield = formfield((state) => current().text.formfield[state]) @@ -78,6 +79,7 @@ export function createComponentTheme(current: Accessor) { hue, increase: (color: RGBA, amount = 1) => current().increase(color, amount), decrease: (color: RGBA, amount = 1) => current().decrease(color, amount), + raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)), text, background, border: () => current().border.default, diff --git a/packages/tui/src/theme/v2/solid.ts b/packages/tui/src/theme/v2/solid.ts index 2e6364c4e243..d7088d208bda 100644 --- a/packages/tui/src/theme/v2/solid.ts +++ b/packages/tui/src/theme/v2/solid.ts @@ -1,18 +1,20 @@ import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js" import { createComponentTheme, type ComponentTheme } from "./component" -import type { ContextKey, ResolvedTheme } from "./index" +import type { ContextKey, Mode, ResolvedTheme } from "./index" type ThemeRuntime = { readonly resolved: Accessor + readonly mode: Accessor readonly component: ComponentTheme } const ThemeContext = createContext() -export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme }>) { +export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme; mode?: Mode }>) { const resolved = () => props.theme + const mode = () => props.mode ?? "light" return createComponent(ThemeContext.Provider, { - value: { resolved, component: createComponentTheme(resolved) }, + value: { resolved, mode, component: createComponentTheme(resolved, mode) }, get children() { return props.children }, @@ -28,7 +30,7 @@ export function ContextProvider(props: ParentProps<{ context: ContextKey }>) { } context() return createComponent(ThemeContext.Provider, { - value: { resolved: parent.resolved, component: createComponentTheme(context) }, + value: { resolved: parent.resolved, mode: parent.mode, component: createComponentTheme(context, parent.mode) }, get children() { return props.children }, diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 0c78db2b6182..d02de90932f8 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -9,17 +9,19 @@ import type { ContextKey } from "../../../src/theme/v2" test("provides reactive property, variant, state, and context accessors", () => { const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) + const [mode, setMode] = createSignal<"light" | "dark">("light") const [context, setContext] = createSignal() const theme = createComponentTheme(() => { const key = context() return key ? resolved().contexts[key] ?? resolved() : resolved() - }) + }, mode) expect(theme.text()).toBe(resolved().text.default) expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500]) expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500]) expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200]) expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[300]) + expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[300]) expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100]) expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900]) expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100]) @@ -81,6 +83,8 @@ test("provides reactive property, variant, state, and context accessors", () => ) setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) + setMode("dark") expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default) expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700]) + expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[700]) })