Compare commits

...

2 Commits

Author SHA1 Message Date
James Long 82fc68aae8 docs: add permission prompt screenshot 2026-07-17 11:33:39 -04:00
James Long 26f3dbe612 fix(tui): raise neutral permission backgrounds 2026-07-17 15:25:47 +00:00
8 changed files with 41 additions and 42 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+2 -6
View File
@@ -188,7 +188,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer() const renderer = useRenderer()
const exit = useExit() const exit = useExit()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const { themeV2, syntax, mode } = useTheme() const { themeV2, syntax } = useTheme()
const animationsEnabled = createMemo(() => config.animations ?? true) const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? []) const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? []) 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 maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() => const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset()))
mode() === "light"
? themeV2.increase(themeV2.background.surface.offset(), 1)
: themeV2.decrease(themeV2.background.surface.offset(), 1),
)
return ( return (
<> <>
+4 -3
View File
@@ -297,18 +297,19 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted)) themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
return result return result
}) })
const themeV2 = createComponentTheme(valuesV2) const mode = () => store.mode
const themeV2 = createComponentTheme(valuesV2, mode)
const contextsV2 = { const contextsV2 = {
elevated: createComponentTheme(() => { elevated: createComponentTheme(() => {
const theme = valuesV2().contexts["@context:elevated"] const theme = valuesV2().contexts["@context:elevated"]
if (!theme) throw new Error("Theme context is not defined: elevated") if (!theme) throw new Error("Theme context is not defined: elevated")
return theme return theme
}), }, mode),
overlay: createComponentTheme(() => { overlay: createComponentTheme(() => {
const theme = valuesV2().contexts["@context:overlay"] const theme = valuesV2().contexts["@context:overlay"]
if (!theme) throw new Error("Theme context is not defined: overlay") if (!theme) throw new Error("Theme context is not defined: overlay")
return theme return theme
}), }, mode),
} }
createEffect(() => renderer.setBackgroundColor(values().background)) createEffect(() => renderer.setBackgroundColor(values().background))
+19 -25
View File
@@ -74,7 +74,6 @@ 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"
import { stringWidth } from "../../util/string-width" import { stringWidth } from "../../util/string-width"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -100,11 +99,6 @@ 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()
@@ -119,7 +113,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, mode } = useTheme() const { themeV2 } = 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)
@@ -898,7 +892,7 @@ export function Session() {
paddingLeft: 1, paddingLeft: 1,
visible: showScrollbar(), visible: showScrollbar(),
trackOptions: { trackOptions: {
backgroundColor: offsetBackground(themeV2, mode()), backgroundColor: themeV2.raise(themeV2.background.surface.offset()),
foregroundColor: themeV2.border(), foregroundColor: themeV2.border(),
}, },
}} }}
@@ -1140,7 +1134,7 @@ function SessionReasoningGroupView(props: {
message: (messageID: string) => SessionMessageInfo | undefined message: (messageID: string) => SessionMessageInfo | undefined
}) { }) {
const ctx = use() const ctx = use()
const { themeV2, syntax, mode } = useTheme() const { themeV2, syntax } = 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)
@@ -1229,7 +1223,7 @@ function SessionReasoningGroupView(props: {
<box <box
border={["left"]} border={["left"]}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
borderColor={offsetBackground(themeV2, mode())} borderColor={themeV2.raise(themeV2.background.surface.offset())}
paddingLeft={1} paddingLeft={1}
> >
<code <code
@@ -1513,7 +1507,7 @@ function RevertMessage(props: {
}> }>
}) { }) {
const ctx = use() const ctx = use()
const { themeV2, mode } = useTheme().contextual("elevated") const { themeV2 } = useTheme().contextual("elevated")
const route = useRouteData("session") const route = useRouteData("session")
const client = useClient() const client = useClient()
const toast = useToast() const toast = useToast()
@@ -1544,7 +1538,7 @@ function RevertMessage(props: {
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
paddingLeft={2} paddingLeft={2}
backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()} backgroundColor={hover() ? themeV2.raise(themeV2.background()) : 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
@@ -1613,7 +1607,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, mode } = useTheme().contextual("elevated") const { themeV2 } = 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(
@@ -1650,7 +1644,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
paddingLeft={2} paddingLeft={2}
backgroundColor={hover() ? offsetBackground(themeV2, mode()) : themeV2.background()} backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
flexShrink={0} flexShrink={0}
> >
<text fg={themeV2.text()}>{props.message.text}</text> <text fg={themeV2.text()}>{props.message.text}</text>
@@ -1670,7 +1664,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
> >
{` ${label} `} {` ${label} `}
</span> </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")}{" "} {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
</span> </span>
@@ -1858,7 +1852,7 @@ function ReasoningPart(props: {
part: SessionMessageAssistantReasoning part: SessionMessageAssistantReasoning
message: SessionMessageAssistant message: SessionMessageAssistant
}) { }) {
const { themeV2, syntax, mode } = useTheme() const { themeV2, syntax } = 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.
@@ -1886,7 +1880,7 @@ function ReasoningPart(props: {
<box <box
border={!inMinimal() || expanded() ? ["left"] : undefined} border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
borderColor={offsetBackground(themeV2, mode())} borderColor={themeV2.raise(themeV2.background())}
paddingLeft={!inMinimal() || expanded() ? 1 : 0} paddingLeft={!inMinimal() || expanded() ? 1 : 0}
> >
<box onMouseUp={toggle}> <box onMouseUp={toggle}>
@@ -1904,7 +1898,7 @@ function ReasoningPart(props: {
<box <box
border={["left"]} border={["left"]}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
borderColor={offsetBackground(themeV2, mode())} borderColor={themeV2.raise(themeV2.background())}
paddingLeft={inMinimal() ? 3 : 1} paddingLeft={inMinimal() ? 3 : 1}
> >
<code <code
@@ -2081,7 +2075,7 @@ type ToolProps = {
part: SessionMessageAssistantTool part: SessionMessageAssistantTool
} }
function GenericTool(props: ToolProps) { function GenericTool(props: ToolProps) {
const { themeV2, syntax, mode } = useTheme() const { themeV2, syntax } = 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)
@@ -2099,7 +2093,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: offsetBackground(themeV2, mode()), fg: themeV2.text.subdued() }}> Input </span> <span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}> Input </span>
</text> </text>
<box paddingLeft={1}> <box paddingLeft={1}>
<code <code
@@ -2117,7 +2111,7 @@ function GenericTool(props: ToolProps) {
{(value) => ( {(value) => (
<box gap={1}> <box gap={1}>
<text> <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> </text>
<box paddingLeft={1}> <box paddingLeft={1}>
<text fg={themeV2.text()} wrapMode="word"> <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 }) { function StatusBadge(props: { children: string }) {
const { themeV2, mode } = useTheme() const { themeV2 } = useTheme()
return ( 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}{" "} {props.children}{" "}
</text> </text>
@@ -2322,7 +2316,7 @@ function BlockTool(props: {
part?: SessionMessageAssistantTool part?: SessionMessageAssistantTool
spinner?: boolean spinner?: boolean
}) { }) {
const { themeV2, mode } = useTheme().contextual("elevated") const { themeV2 } = useTheme().contextual("elevated")
const ctx = use() const ctx = use()
const data = useData() const data = useData()
const renderer = useRenderer() const renderer = useRenderer()
@@ -2341,7 +2335,7 @@ function BlockTool(props: {
paddingLeft={2} paddingLeft={2}
gap={1} gap={1}
backgroundColor={ backgroundColor={
hover() ? offsetBackground(themeV2, mode()) : themeV2.background() hover() ? themeV2.raise(themeV2.background()) : themeV2.background()
} }
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.background()} borderColor={themeV2.background()}
@@ -516,7 +516,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
paddingLeft={2} paddingLeft={2}
paddingRight={3} paddingRight={3}
paddingBottom={1} paddingBottom={1}
backgroundColor={themeV2.background.action("focused")} backgroundColor={themeV2.raise(themeV2.background())}
justifyContent={narrow() ? "flex-start" : "space-between"} justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"} alignItems={narrow() ? "flex-start" : "center"}
gap={1} gap={1}
@@ -693,7 +693,7 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingLeft={2} paddingLeft={2}
paddingRight={3} paddingRight={3}
paddingBottom={1} paddingBottom={1}
backgroundColor={themeV2.background.action("focused")} backgroundColor={themeV2.raise(themeV2.background())}
justifyContent={narrow() ? "flex-start" : "space-between"} justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"} alignItems={narrow() ? "flex-start" : "center"}
> >
+3 -1
View File
@@ -2,6 +2,7 @@ import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { import type {
ActionVariant, ActionVariant,
Mode,
ResolvedActionState, ResolvedActionState,
ResolvedThemeView, ResolvedThemeView,
} from "./index" } from "./index"
@@ -9,7 +10,7 @@ import { ActionState, HueStep } from "./schema"
type StateFlags = Partial<Record<ActionState, boolean>> 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 textAction = actions((variant, state) => current().text.action[variant][state])
const backgroundAction = actions((variant, state) => current().background.action[variant][state]) const backgroundAction = actions((variant, state) => current().background.action[variant][state])
const textFormfield = formfield((state) => current().text.formfield[state]) const textFormfield = formfield((state) => current().text.formfield[state])
@@ -78,6 +79,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
hue, hue,
increase: (color: RGBA, amount = 1) => current().increase(color, amount), increase: (color: RGBA, amount = 1) => current().increase(color, amount),
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount), decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
text, text,
background, background,
border: () => current().border.default, border: () => current().border.default,
+6 -4
View File
@@ -1,18 +1,20 @@
import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js" import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "./component" import { createComponentTheme, type ComponentTheme } from "./component"
import type { ContextKey, ResolvedTheme } from "./index" import type { ContextKey, Mode, ResolvedTheme } from "./index"
type ThemeRuntime = { type ThemeRuntime = {
readonly resolved: Accessor<ResolvedTheme> readonly resolved: Accessor<ResolvedTheme>
readonly mode: Accessor<Mode>
readonly component: ComponentTheme readonly component: ComponentTheme
} }
const ThemeContext = createContext<ThemeRuntime>() 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 resolved = () => props.theme
const mode = () => props.mode ?? "light"
return createComponent(ThemeContext.Provider, { return createComponent(ThemeContext.Provider, {
value: { resolved, component: createComponentTheme(resolved) }, value: { resolved, mode, component: createComponentTheme(resolved, mode) },
get children() { get children() {
return props.children return props.children
}, },
@@ -28,7 +30,7 @@ export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
} }
context() context()
return createComponent(ThemeContext.Provider, { 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() { get children() {
return props.children return props.children
}, },
+5 -1
View File
@@ -9,17 +9,19 @@ import type { ContextKey } from "../../../src/theme/v2"
test("provides reactive property, variant, state, and context accessors", () => { test("provides reactive property, variant, state, and context accessors", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
const [mode, setMode] = createSignal<"light" | "dark">("light")
const [context, setContext] = createSignal<ContextKey>() const [context, setContext] = createSignal<ContextKey>()
const theme = createComponentTheme(() => { const theme = createComponentTheme(() => {
const key = context() const key = context()
return key ? resolved().contexts[key] ?? resolved() : resolved() return key ? resolved().contexts[key] ?? resolved() : resolved()
}) }, mode)
expect(theme.text()).toBe(resolved().text.default) expect(theme.text()).toBe(resolved().text.default)
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500]) expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500]) expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200]) 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.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.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.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900])
expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100]) 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"))) setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setMode("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]) 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])
}) })