Compare commits

...
Author SHA1 Message Date
iamdavidhill afb8358e82 fix(tui): adapt logo shadows to terminal backgrounds 2026-08-27 22:05:49 +00:00
9 changed files with 327 additions and 47 deletions
+16 -7
View File
@@ -84,6 +84,7 @@ async function open(from?: string): Promise<Session> {
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
const [backgroundKnown, setBackgroundKnown] = createSignal(false)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
@@ -102,6 +103,16 @@ async function open(from?: string): Promise<Session> {
consoleMode: "disabled",
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
void renderer.getPalette({ size: 16 }).then(
(colors) => {
if (!colors.defaultBackground || renderer.isDestroyed) return
const background = RGBA.fromHex(colors.defaultBackground)
background.a = 0
renderer.setBackgroundColor(background)
setBackgroundKnown(true)
},
() => {},
)
await render(
() => (
<Show when={visible()}>
@@ -112,6 +123,7 @@ async function open(from?: string): Promise<Session> {
failure={failure}
animating={animating}
renderer={renderer}
backgroundKnown={backgroundKnown}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
@@ -242,11 +254,7 @@ const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>):
...styled(segment[0], segment[1], segment[2]),
])
function Monogram(props: { ink: () => RGBA }) {
const shadow = createMemo(() => {
const ink = props.ink()
return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
})
function Monogram(props: { ink: () => RGBA; backgroundKnown: () => boolean }) {
return (
<box flexDirection="column">
<For each={monogram}>
@@ -255,7 +263,7 @@ function Monogram(props: { ink: () => RGBA }) {
<For each={Array.from(line)}>
{(char) =>
char === "_" ? (
<text bg={shadow()} selectable={false}>
<text bg={props.ink()} opacity={props.backgroundKnown() ? 0.25 : 0} selectable={false}>
{" "}
</text>
) : (
@@ -338,6 +346,7 @@ function UpdateFooter(props: {
failure: () => string
animating: () => boolean
renderer: CliRenderer
backgroundKnown: () => boolean
onOutcomeSettled: () => void
}) {
const term = useTerminalDimensions()
@@ -448,7 +457,7 @@ function UpdateFooter(props: {
return (
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
<Monogram ink={monogramInk} />
<Monogram ink={monogramInk} backgroundKnown={props.backgroundKnown} />
<box flexDirection="column" flexGrow={1} overflow="hidden">
<CellLine cells={header()} />
<Show
+12 -6
View File
@@ -1,21 +1,27 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { useTheme, useThemes } from "../context/theme"
import { go, logo } from "../logo"
export function Logo() {
const theme = useTheme()
const themes = useThemes()
const dimensions = useTerminalDimensions()
const opacity = () =>
(theme.background.default.a === 1 && theme.background.default.intent !== "default") ||
themes.terminalBackgroundKnown()
? 0.25
: 0
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background.default, fg, 0.25)
const shadow = RGBA.clone(fg)
shadow.a = opacity()
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char) => {
if (char === "_") {
return (
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
<text fg={fg} bg={fg} opacity={opacity()} attributes={attrs} selectable={false}>
{" "}
</text>
)
@@ -29,14 +35,14 @@ export function Logo() {
}
if (char === "~") {
return (
<text fg={shadow} attributes={attrs} selectable={false}>
<text fg={fg} opacity={opacity()} attributes={attrs} selectable={false}>
</text>
)
}
if (char === ",") {
return (
<text fg={shadow} attributes={attrs} selectable={false}>
<text fg={fg} opacity={opacity()} attributes={attrs} selectable={false}>
</text>
)
+19 -3
View File
@@ -1,4 +1,4 @@
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { CliRenderEvents, RGBA, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import {
generateSyntax,
@@ -24,7 +24,7 @@ import {
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
@@ -118,6 +118,7 @@ type Themes = {
set(theme: string): boolean
onError(handler: ThemeErrorHandler): () => void
readonly ready: boolean
terminalBackgroundKnown: Accessor<boolean>
}
type ThemeContextValue = {
@@ -140,6 +141,7 @@ const themeContext = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
const renderer = useRenderer()
const [terminalBackground, setTerminalBackground] = createSignal<RGBA>()
const configState = useConfig()
const config = configState.data
const themes = props.source
@@ -197,6 +199,7 @@ const themeContext = createSimpleContext({
return renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
setTerminalBackground(colors.defaultBackground ? RGBA.fromHex(colors.defaultBackground) : undefined)
if (!colors.palette[0]) {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
@@ -213,6 +216,7 @@ const themeContext = createSimpleContext({
setSystemTheme(generateSystem(colors, next))
})
.catch(() => {
setTerminalBackground(undefined)
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
@@ -320,13 +324,25 @@ const themeContext = createSimpleContext({
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(valuesV2, mode)
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
createEffect(() => {
const background = valuesV2().background.default
const terminal = terminalBackground()
if (background.a === 0 && terminal) {
// Supply the compositor's backdrop without painting over terminal transparency.
const transparent = RGBA.clone(terminal)
transparent.a = 0
renderer.setBackgroundColor(transparent)
return
}
renderer.setBackgroundColor(background)
})
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
const service: Themes = {
current,
currentTokens: valuesV2,
currentSyntax,
terminalBackgroundKnown: () => terminalBackground() !== undefined,
get selected() {
return store.active
},
+2
View File
@@ -252,6 +252,8 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
top: 0,
width,
height,
// Scrollback snapshots have their own buffer, separate from the renderer background.
backgroundColor: input.theme.background,
})
for (const line of lines) {
+16 -15
View File
@@ -25,6 +25,7 @@ export type RunSplashTheme = {
left: ColorInput
right: ColorInput
leftShadow: ColorInput
background?: ColorInput
}
export type RunFooterTheme = {
@@ -180,11 +181,6 @@ function paletteColor(colors: TerminalColors, index: number): RGBA {
return value ? RGBA.fromHex(value) : ansiToRgba(index)
}
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
const mixed = tint(base, overlay, value)
return nearestIndexed(indexed, mixed)
}
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): ThemeCurrent {
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
return {
@@ -354,13 +350,17 @@ function quantizeTheme(theme: ThemeCurrent, indexed: RGBA[]): ThemeCurrent {
}
}
function splashTheme(theme: ThemeCurrent, indexed: RGBA[]): RunSplashTheme {
function splashTheme(theme: ThemeCurrent, indexed: RGBA[], background: string | null): RunSplashTheme {
if (!background) {
return { left: theme.text, right: theme.text, leftShadow: transparent }
}
const left = nearestIndexed(indexed, theme.textMuted)
const right = nearestIndexed(indexed, theme.text)
return {
left,
right,
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
leftShadow: alpha(left, 0.25),
background: RGBA.defaultBackground(background),
}
}
@@ -457,10 +457,6 @@ function tone(body: ColorInput, start?: ColorInput): Tone {
}
}
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
const fallbackSplashLeft = RGBA.fromIndex(67)
const fallbackSplashRight = RGBA.fromIndex(110)
export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
@@ -488,9 +484,9 @@ export const RUN_THEME_FALLBACK: RunTheme = {
error: tone(seed.error),
},
splash: {
left: fallbackSplashLeft,
right: fallbackSplashRight,
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
left: seed.text,
right: seed.text,
leftShadow: transparent,
},
block: {
text: seed.text,
@@ -595,7 +591,12 @@ export async function resolveRunTheme(
...scrollbackTheme,
_hasSelectedListItemText: true,
}
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed, colors.defaultBackground),
generateSyntax(syntaxTheme),
)
} catch {
return RUN_THEME_FALLBACK
}
+3 -13
View File
@@ -5,21 +5,11 @@ const bold = "\x1b[1m"
const dim = "\x1b[90m"
function wordmark(pad = "") {
const draw = (line: string, fg: string, shadow: string, bg: string) =>
[...line]
.map((char) => {
if (char === "_") return `${bg} ${reset}`
if (char === "^") return `${fg}${bg}${reset}`
if (char === "~") return `${shadow}${reset}`
if (char === " ") return " "
return `${fg}${char}${reset}`
})
.join("")
// Outside the renderer the terminal background is unknown. Keep only the letter faces.
const draw = (line: string) => line.replace(/[_~,]/g, " ").replace(/\^/g, "▀")
return logo.left.map((line, index) => {
const left = draw(line, dim, "\x1b[38;5;235m", "\x1b[48;5;235m")
const right = draw(logo.right[index] ?? "", reset, "\x1b[38;5;238m", "\x1b[48;5;238m")
return `${pad}${left} ${right}`
return `${reset}${pad}${draw(line)} ${draw(logo.right[index] ?? "")}`
})
}
+172
View File
@@ -0,0 +1,172 @@
/** @jsxImportSource @opentui/solid */
import { RGBA, type TerminalColors } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { render } from "@opentui/solid"
import { expect, spyOn, test } from "bun:test"
import { Logo } from "../../../src/component/logo"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const palette: TerminalColors = {
palette: Array.from({ length: 16 }, () => "#000000"),
defaultForeground: "#eeeeee",
defaultBackground: null,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
}
async function setup(background: string, surface: string | undefined, colors = palette) {
const app = await createTestRenderer({ width: 80, height: 24 })
const query = spyOn(app.renderer, "getPalette").mockResolvedValue(colors)
let themes: ReturnType<typeof useThemes> | undefined
function Content() {
themes = useThemes()
return (
<box backgroundColor={surface} width="100%" height="100%">
<Logo />
</box>
)
}
await render(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "logo", mode: "dark" } })}>
<ThemeProvider
mode="dark"
source={{
discover: async () => ({
logo: {
version: 2,
dark: { background: { default: background }, text: { default: "#eeeeee", subdued: "#eeeeee" } },
},
}),
}}
>
<Content />
</ThemeProvider>
</ConfigProvider>
),
app.renderer,
)
await app.waitFor(() => themes?.ready === true)
await app.renderOnce()
return {
...app,
async palette(colors: TerminalColors) {
query.mockResolvedValue(colors)
await app.mockInput.pressKeys(["\x1b[?997;1n"])
await app.waitFor(() => themes?.terminalBackgroundKnown() === !!colors.defaultBackground)
await app.renderOnce()
},
cell(x: number, y: number) {
return app.captureSpans().lines[y].spans.flatMap((span) => Array.from(span.text, (char) => ({ ...span, char })))[
x
]
},
close() {
query.mockRestore()
app.renderer.destroy()
},
}
}
test.each(["#fdf6e3", "#18181b", "#292237"])("composites logo shadows over the actual %s surface", async (surface) => {
// A different opaque theme background catches preblending against the wrong surface.
const app = await setup("#000000", surface)
try {
const bg = RGBA.fromHex(surface).toInts()
const expected = bg.slice(0, 3).map((channel) => Math.round(channel * 0.75 + 238 * 0.25))
const full = app.cell(1, 2).bg.toInts()
const mixed = app.cell(11, 2)
const top = app.cell(16, 3).fg.toInts()
for (const actual of [full, mixed.bg.toInts(), top]) {
expected.forEach((channel, index) => expect(Math.abs(actual[index] - channel)).toBeLessThanOrEqual(1))
}
expect(mixed.char).toBe("▀")
expect(mixed.fg.toInts()).toEqual([238, 238, 238, 255])
} finally {
app.close()
}
})
test("leaves only letter faces when a transparent theme has no terminal background evidence", async () => {
const app = await setup("transparent", "#fdf6e3")
try {
expect(app.cell(1, 2).bg.toInts()).toEqual(RGBA.fromHex("#fdf6e3").toInts())
expect(app.cell(11, 2).char).toBe("▀")
expect(app.cell(11, 2).bg.toInts()).toEqual(RGBA.fromHex("#fdf6e3").toInts())
expect(app.cell(16, 3).char).toBe(" ")
} finally {
app.close()
}
})
test("keeps shadows for a transparent theme with a detected terminal background", async () => {
const app = await setup("transparent", "#fdf6e3", { ...palette, defaultBackground: "#fdf6e3" })
try {
expect(app.cell(1, 2).bg.toInts()).not.toEqual(RGBA.fromHex("#fdf6e3").toInts())
expect(app.cell(16, 3).char).toBe("▀")
} finally {
app.close()
}
})
test.each(["#fdf6e3", "#18181b", "#292237"])(
"uses detected %s as the transparent compositor backdrop",
async (background) => {
const app = await setup("transparent", undefined, { ...palette, defaultBackground: background })
try {
const base = RGBA.fromHex(background).toInts()
const expected = base.slice(0, 3).map((channel) => Math.round(channel * 0.75 + 238 * 0.25))
for (const actual of [app.cell(1, 2).bg.toInts(), app.cell(11, 2).bg.toInts(), app.cell(16, 3).fg.toInts()]) {
expected.forEach((channel, index) => expect(Math.abs(actual[index] - channel)).toBeLessThanOrEqual(1))
}
expect(app.cell(0, 0).bg.a).toBe(0)
} finally {
app.close()
}
},
)
test("updates every shadow cell when terminal background detection changes", async () => {
const app = await setup("transparent", "#fdf6e3")
try {
const background = app.cell(1, 2).bg.toInts()
await app.palette({ ...palette, defaultBackground: "#fdf6e3" })
expect(app.cell(1, 2).bg.toInts()).not.toEqual(background)
expect(app.cell(11, 2).bg.toInts()).not.toEqual(background)
expect(app.cell(16, 3).char).toBe("▀")
await app.palette(palette)
expect(app.cell(1, 2).bg.toInts()).toEqual(background)
expect(app.cell(11, 2).bg.toInts()).toEqual(background)
expect(app.cell(16, 3).char).toBe(" ")
} finally {
app.close()
}
})
test.each([
[80, 24, 4],
[30, 24, 7],
[20, 24, 3],
[80, 11, 0],
])("preserves logo layout at %ix%i", async (width, height, rows) => {
const app = await setup("#18181b", "#18181b")
try {
app.resize(width, height)
await app.renderOnce()
expect(
app
.captureCharFrame()
.split("\n")
.filter((line) => line.trim()).length,
).toBe(rows)
} finally {
app.close()
}
})
+69 -3
View File
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { CliRenderEvents, RGBA, type CapturedLine, type CliRenderer, type TerminalColors } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { entrySplash, exitSplash } from "../../src/mini/splash"
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
import { DEFAULT_THEMES } from "../../src/theme"
@@ -92,7 +94,7 @@ test("resolveTheme preserves Mini indexed color and result shape semantics", ()
expect("_hasSelectedListItemText" in theme).toBe(false)
})
test("returns syntax styles and indexed splash colors", async () => {
test("returns syntax styles and native alpha splash shadows", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
try {
@@ -100,7 +102,10 @@ test("returns syntax styles and indexed splash colors", async () => {
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
const shadow = expectRgba(theme.splash.leftShadow)
expect(shadow.intent).toBe("rgb")
expect(shadow.a).toBeCloseTo(0.25, 2)
expect(shadow.toInts().slice(0, 3)).toEqual(expectRgba(theme.splash.left).toInts().slice(0, 3))
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.statusAccent)
expectRgba(theme.footer.surface)
@@ -110,6 +115,67 @@ test("returns syntax styles and indexed splash colors", async () => {
}
})
test("omits splash shadows without an actual terminal background", async () => {
const theme = await resolveRunTheme(renderer({ colors: { ...terminalColors(), defaultBackground: null } }))
try {
expect(expectRgba(theme.splash.leftShadow).a).toBe(0)
expect(expectRgba(theme.splash.left).intent).toBe("default")
expect(expectRgba(theme.splash.right).intent).toBe("default")
expect(expectRgba(theme.splash.left).toInts()).toEqual(expectRgba(theme.footer.text).toInts())
} finally {
theme.block.syntax?.destroy()
}
})
test("fallback splash uses default foreground without shadows", () => {
expect(expectRgba(RUN_THEME_FALLBACK.splash.leftShadow).a).toBe(0)
expect(expectRgba(RUN_THEME_FALLBACK.splash.left).intent).toBe("default")
expect(expectRgba(RUN_THEME_FALLBACK.splash.right).intent).toBe("default")
})
test("native scrollback composes splash shadows against the reported background", async () => {
for (const background of ["#101820", "#faf0dc", "#0000ff", null]) {
const theme = await resolveRunTheme(renderer({ colors: { ...terminalColors(), defaultBackground: background } }))
const out = await createTestRenderer({
width: 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
out.renderer.setBackgroundColor(theme.background)
let lines: CapturedLine[] = []
let text = ""
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => {
lines = event.snapshot.getSpanLines()
text = new TextDecoder().decode(event.snapshot.getRealCharBytes(false))
})
try {
for (const splash of [entrySplash, exitSplash]) {
out.renderer.writeToScrollback(splash({ theme: theme.splash, title: "Test", session_id: "ses-test" }))
expect([1, 2, 3].map((row) => text.slice(row * 80, row * 80 + 4))).toEqual(["█▀▀█", "█ █", "▀▀▀▀"])
const interior = lines[2]!.spans[lines[2]!.spans[0]!.width > 1 ? 0 : 1]!
expect(interior).toBeDefined()
if (!background) {
expect(interior.bg.a).toBe(0)
continue
}
const foreground = expectRgba(theme.splash.left)
const base = RGBA.fromHex(background)
expect(interior.bg.a).toBe(1)
expect(interior.bg.r).toBeCloseTo(base.r * 0.75 + foreground.r * 0.25, 2)
expect(interior.bg.g).toBeCloseTo(base.g * 0.75 + foreground.g * 0.25, 2)
expect(interior.bg.b).toBeCloseTo(base.b * 0.75 + foreground.b * 0.25, 2)
}
} finally {
out.renderer.destroy()
theme.block.syntax?.destroy()
}
}
})
test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
const colors = terminalColors({
defaultBackground: "#0f172a",
@@ -6,3 +6,21 @@ test("formats session continuation summary", () => {
expect(epilogue).toContain("A session")
expect(epilogue).toContain("opencode2 -s ses_123")
})
test("uses the terminal foreground without painting shadows when the background is unknown", () => {
const output = sessionEpilogue({ title: "Logo", sessionID: "ses_logo" })
const mark = output.split("\n").slice(0, 4).join("\n")
expect(mark).not.toMatch(/\x1b\[(?:38|48);/)
expect(mark).not.toContain("\x1b[90m")
expect(Bun.stripANSI(mark)).toBe(
[
" ▄ ",
" █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█",
" █ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀",
" ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀",
].join("\n"),
)
expect(Bun.stripANSI(output)).toContain("Session Logo")
expect(Bun.stripANSI(output)).toContain("Continue opencode2 -s ses_logo")
})