mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 22:46:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb8358e82 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] ?? "")}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user