Przeglądaj źródła

fix(tui): derive session surface colors from hues (#37401)

James Long 4 tygodni temu
rodzic
commit
f76e18201a

+ 25 - 19
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: {
                         <box
                           border={["left"]}
                           customBorderChars={SplitBorder.customBorderChars}
-                          borderColor={themeV2.background.action("focused")}
+                          borderColor={offsetBackground(themeV2, mode())}
                           paddingLeft={1}
                         >
                           <code
@@ -1507,7 +1513,7 @@ function RevertMessage(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()}
       >
         <text fg={themeV2.text.subdued()}>
           {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}
         >
           <text fg={themeV2.text()}>{props.message.text}</text>
@@ -1664,7 +1670,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
                       >
                         {` ${label} `}
                       </span>
-                      <span style={{ bg: themeV2.background.action("focused"), fg: themeV2.text.subdued() }}>
+                      <span style={{ bg: offsetBackground(themeV2, mode()), fg: themeV2.text.subdued() }}>
                         {" "}
                         {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
                       </span>
@@ -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: {
         <box
           border={!inMinimal() || expanded() ? ["left"] : undefined}
           customBorderChars={SplitBorder.customBorderChars}
-          borderColor={themeV2.background.action("focused")}
+          borderColor={offsetBackground(themeV2, mode())}
           paddingLeft={!inMinimal() || expanded() ? 1 : 0}
         >
           <box onMouseUp={toggle}>
@@ -1898,7 +1904,7 @@ function ReasoningPart(props: {
             <box
               border={["left"]}
               customBorderChars={SplitBorder.customBorderChars}
-              borderColor={themeV2.background.action("focused")}
+              borderColor={offsetBackground(themeV2, mode())}
               paddingLeft={inMinimal() ? 3 : 1}
             >
               <code
@@ -2075,7 +2081,7 @@ type ToolProps = {
   part: SessionMessageAssistantTool
 }
 function GenericTool(props: ToolProps) {
-  const { themeV2, syntax } = useTheme()
+  const { themeV2, syntax, mode } = useTheme()
   const output = createMemo(() => 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) {
           <Show when={Object.keys(props.input).length > 0}>
             <box gap={1}>
               <text>
-                <span style={{ bg: themeV2.background.action("focused"), fg: themeV2.text.subdued() }}> Input </span>
+                <span style={{ bg: offsetBackground(themeV2, mode()), fg: themeV2.text.subdued() }}> Input </span>
               </text>
               <box paddingLeft={1}>
                 <code
@@ -2111,7 +2117,7 @@ function GenericTool(props: ToolProps) {
             {(value) => (
               <box gap={1}>
                 <text>
-                  <span style={{ bg: themeV2.background.action("focused"), fg: themeV2.text.subdued() }}> Output </span>
+                  <span style={{ bg: offsetBackground(themeV2, mode()), fg: themeV2.text.subdued() }}> Output </span>
                 </text>
                 <box paddingLeft={1}>
                   <text fg={themeV2.text()} wrapMode="word">
@@ -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 (
-    <text flexShrink={0} bg={themeV2.background.action("focused")} fg={themeV2.text.subdued()}>
+    <text flexShrink={0} bg={offsetBackground(themeV2, mode())} fg={themeV2.text.subdued()}>
       {" "}
       {props.children}{" "}
     </text>
@@ -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()}

+ 2 - 13
packages/tui/src/theme/v2/component.ts

@@ -78,8 +78,8 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
 
   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<ResolvedThemeView>) {
   }
 }
 
-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)

+ 32 - 4
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, "increase" | "decrease">,
+): ResolvedThemeView {
   const source: Record<string, unknown> = { 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<ResolvedThemeView, "increase" | "decrease"> {
+  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) {

+ 2 - 0
packages/tui/src/theme/v2/types.ts

@@ -21,6 +21,8 @@ export type FormfieldColor = Readonly<Record<ResolvedFormfieldState, RGBA>>
 
 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

+ 1 - 0
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])
 })

+ 5 - 0
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])