Browse Source

fix(tui): raise neutral permission backgrounds (#37513)

James Long 3 tuần trước cách đây
mục cha
commit
bd79e24842

+ 2 - 6
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 (
     <>

+ 4 - 3
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))

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

+ 2 - 2
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<const T extends Record<string, string>>(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"}
       >

+ 3 - 1
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<Record<ActionState, boolean>>
 
-export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
+export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
   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<ResolvedThemeView>) {
     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,

+ 6 - 4
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<ResolvedTheme>
+  readonly mode: Accessor<Mode>
   readonly component: ComponentTheme
 }
 
 const ThemeContext = createContext<ThemeRuntime>()
 
-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
     },

+ 5 - 1
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<ContextKey>()
   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])
 })