fix(tui): derive session surface colors from hues (#37401)
This commit is contained in:
parent
331533deff
commit
f76e18201a
6 changed files with 67 additions and 36 deletions
|
|
@ -74,6 +74,7 @@ import { useLocation } from "../../context/location"
|
||||||
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||||
import { switchLabel } from "../../util/model"
|
import { switchLabel } from "../../util/model"
|
||||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||||
|
import type { ComponentTheme } from "../../theme/v2/component"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
|
|
@ -98,6 +99,11 @@ function use() {
|
||||||
return ctx
|
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() {
|
export function Session() {
|
||||||
const setEpilogue = useEpilogue()
|
const setEpilogue = useEpilogue()
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
|
|
@ -112,7 +118,7 @@ export function Session() {
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const configState = useConfig()
|
const configState = useConfig()
|
||||||
const config = configState.data
|
const config = configState.data
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2, mode } = useTheme()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const session = createMemo(() => data.session.get(route.sessionID))
|
const session = createMemo(() => data.session.get(route.sessionID))
|
||||||
const messages = () => data.session.message.list(route.sessionID)
|
const messages = () => data.session.message.list(route.sessionID)
|
||||||
|
|
@ -891,7 +897,7 @@ export function Session() {
|
||||||
paddingLeft: 1,
|
paddingLeft: 1,
|
||||||
visible: showScrollbar(),
|
visible: showScrollbar(),
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: themeV2.background.action("focused"),
|
backgroundColor: offsetBackground(themeV2, mode()),
|
||||||
foregroundColor: themeV2.border(),
|
foregroundColor: themeV2.border(),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|
@ -1133,7 +1139,7 @@ function SessionReasoningGroupView(props: {
|
||||||
message: (messageID: string) => SessionMessageInfo | undefined
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
}) {
|
}) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { themeV2, syntax } = useTheme()
|
const { themeV2, syntax, mode } = useTheme()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
|
|
@ -1222,7 +1228,7 @@ function SessionReasoningGroupView(props: {
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
borderColor={themeV2.background.action("focused")}
|
borderColor={offsetBackground(themeV2, mode())}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
>
|
>
|
||||||
<code
|
<code
|
||||||
|
|
@ -1507,7 +1513,7 @@ function RevertMessage(props: {
|
||||||
}>
|
}>
|
||||||
}) {
|
}) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
@ -1538,7 +1544,7 @@ function RevertMessage(props: {
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
backgroundColor={hover() ? themeV2.background.action("focused") : themeV2.background()}
|
backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()}
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text.subdued()}>
|
<text fg={themeV2.text.subdued()}>
|
||||||
{props.count} message{props.count === 1 ? "" : "s"} reverted
|
{props.count} message{props.count === 1 ? "" : "s"} reverted
|
||||||
|
|
@ -1607,7 +1613,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const files = createMemo(() => props.message.files ?? [])
|
const files = createMemo(() => props.message.files ?? [])
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||||
const queued = createMemo(
|
const queued = createMemo(
|
||||||
|
|
@ -1644,7 +1650,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
backgroundColor={hover() ? themeV2.background.action("focused") : themeV2.background()}
|
backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text()}>{props.message.text}</text>
|
<text fg={themeV2.text()}>{props.message.text}</text>
|
||||||
|
|
@ -1664,7 +1670,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||||
>
|
>
|
||||||
{` ${label} `}
|
{` ${label} `}
|
||||||
</span>
|
</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")}{" "}
|
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1852,7 +1858,7 @@ function ReasoningPart(props: {
|
||||||
part: SessionMessageAssistantReasoning
|
part: SessionMessageAssistantReasoning
|
||||||
message: SessionMessageAssistant
|
message: SessionMessageAssistant
|
||||||
}) {
|
}) {
|
||||||
const { themeV2, syntax } = useTheme()
|
const { themeV2, syntax, mode } = useTheme()
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
// Collapsed by default in hide mode: a single line throughout, so the
|
// 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.
|
// layout never shifts. Click to open the full markdown block, click to close.
|
||||||
|
|
@ -1880,7 +1886,7 @@ function ReasoningPart(props: {
|
||||||
<box
|
<box
|
||||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
borderColor={themeV2.background.action("focused")}
|
borderColor={offsetBackground(themeV2, mode())}
|
||||||
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
||||||
>
|
>
|
||||||
<box onMouseUp={toggle}>
|
<box onMouseUp={toggle}>
|
||||||
|
|
@ -1898,7 +1904,7 @@ function ReasoningPart(props: {
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
borderColor={themeV2.background.action("focused")}
|
borderColor={offsetBackground(themeV2, mode())}
|
||||||
paddingLeft={inMinimal() ? 3 : 1}
|
paddingLeft={inMinimal() ? 3 : 1}
|
||||||
>
|
>
|
||||||
<code
|
<code
|
||||||
|
|
@ -2075,7 +2081,7 @@ type ToolProps = {
|
||||||
part: SessionMessageAssistantTool
|
part: SessionMessageAssistantTool
|
||||||
}
|
}
|
||||||
function GenericTool(props: ToolProps) {
|
function GenericTool(props: ToolProps) {
|
||||||
const { themeV2, syntax } = useTheme()
|
const { themeV2, syntax, mode } = useTheme()
|
||||||
const output = createMemo(() => props.output?.trim() ?? "")
|
const output = createMemo(() => props.output?.trim() ?? "")
|
||||||
const args = createMemo(() => JSON.stringify(props.input, null, 2))
|
const args = createMemo(() => JSON.stringify(props.input, null, 2))
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
|
|
@ -2093,7 +2099,7 @@ function GenericTool(props: ToolProps) {
|
||||||
<Show when={Object.keys(props.input).length > 0}>
|
<Show when={Object.keys(props.input).length > 0}>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text>
|
<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>
|
</text>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<code
|
<code
|
||||||
|
|
@ -2111,7 +2117,7 @@ function GenericTool(props: ToolProps) {
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text>
|
<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>
|
</text>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={themeV2.text()} wrapMode="word">
|
<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 }) {
|
function StatusBadge(props: { children: string }) {
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2, mode } = useTheme()
|
||||||
return (
|
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}{" "}
|
{props.children}{" "}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -2316,7 +2322,7 @@ function BlockTool(props: {
|
||||||
part?: SessionMessageAssistantTool
|
part?: SessionMessageAssistantTool
|
||||||
spinner?: boolean
|
spinner?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
@ -2335,7 +2341,7 @@ function BlockTool(props: {
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
gap={1}
|
gap={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
hover() ? themeV2.background.action("focused") : themeV2.background()
|
hover() ? offsetBackground(themeV2, mode()) : themeV2.background()
|
||||||
}
|
}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
borderColor={themeV2.background()}
|
borderColor={themeV2.background()}
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,8 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hue,
|
hue,
|
||||||
increase: (color: RGBA, amount = 1) => shiftHue(current(), color, amount),
|
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
|
||||||
decrease: (color: RGBA, amount = 1) => shiftHue(current(), color, -amount),
|
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
|
||||||
text,
|
text,
|
||||||
background,
|
background,
|
||||||
border: () => current().border.default,
|
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) {
|
function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
|
||||||
const action = (variant: ActionVariant) => (states: ActionState | "default" | ActionStates = "default") => {
|
const action = (variant: ActionVariant) => (states: ActionState | "default" | ActionStates = "default") => {
|
||||||
if (typeof states === "string") return get(variant, states)
|
if (typeof states === "string") return get(variant, states)
|
||||||
|
|
|
||||||
|
|
@ -70,14 +70,15 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||||
|
|
||||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||||
const hue = resolveHue(definition.hue)
|
const hue = resolveHue(definition.hue)
|
||||||
|
const hueSteps = compileHueSteps(hue)
|
||||||
const base = tokens(definition)
|
const base = tokens(definition)
|
||||||
const resolved = resolveView(base, hue)
|
const resolved = resolveView(base, hue, hueSteps)
|
||||||
const contexts = Object.fromEntries(
|
const contexts = Object.fromEntries(
|
||||||
Object.entries(definition)
|
Object.entries(definition)
|
||||||
.filter(([key]) => key.startsWith("@context:"))
|
.filter(([key]) => key.startsWith("@context:"))
|
||||||
.map(([key, override]) => {
|
.map(([key, override]) => {
|
||||||
const contextual = contextualize(base, override as ThemeTokensDefinition)
|
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 }
|
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) {
|
function resolveHue(definition: HueDefinition) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export type FormfieldColor = Readonly<Record<ResolvedFormfieldState, RGBA>>
|
||||||
|
|
||||||
export type ResolvedThemeView = {
|
export type ResolvedThemeView = {
|
||||||
readonly hue: Hue
|
readonly hue: Hue
|
||||||
|
readonly increase: (color: RGBA, amount?: number) => RGBA
|
||||||
|
readonly decrease: (color: RGBA, amount?: number) => RGBA
|
||||||
readonly text: {
|
readonly text: {
|
||||||
readonly default: RGBA
|
readonly default: RGBA
|
||||||
readonly subdued: RGBA
|
readonly subdued: RGBA
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,5 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
||||||
|
|
||||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||||
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||||
|
expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700])
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ test("resolves independent definitions and hue aliases", () => {
|
||||||
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
|
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
|
||||||
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue)
|
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue)
|
||||||
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
|
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(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||||
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])
|
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue