opencode/run: refresh themes after terminal reloads (#30917)
This commit is contained in:
parent
b278e49e96
commit
0c0d193474
12 changed files with 499 additions and 71 deletions
|
|
@ -1,5 +1,6 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, type BoxRenderable } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
|
|
@ -16,7 +17,7 @@ import {
|
|||
} from "@/cli/cmd/run/footer.command"
|
||||
import { RunFooterView } from "@/cli/cmd/run/footer.view"
|
||||
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
|
||||
import type {
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
|
|
@ -153,6 +154,7 @@ async function renderFooter(
|
|||
input: {
|
||||
tuiConfig?: RunTuiConfig
|
||||
commands?: RunCommand[]
|
||||
theme?: () => RunTheme
|
||||
onCycle?: () => void
|
||||
onSubmit?: (prompt: RunPrompt) => boolean
|
||||
} = {},
|
||||
|
|
@ -183,7 +185,7 @@ async function renderFooter(
|
|||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
theme={RUN_THEME_FALLBACK}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
tuiConfig={config}
|
||||
backgroundSubagents={true}
|
||||
agent="opencode"
|
||||
|
|
@ -227,6 +229,31 @@ async function renderFooter(
|
|||
}
|
||||
}
|
||||
|
||||
test("direct footer updates composer background when theme changes", async () => {
|
||||
const surface = RGBA.fromHex("#123456")
|
||||
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
|
||||
const app = await renderFooter({ theme })
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const area = app.renderer.root.findDescendantById("run-direct-footer-composer-area") as BoxRenderable
|
||||
|
||||
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
|
||||
setTheme({
|
||||
...RUN_THEME_FALLBACK,
|
||||
footer: {
|
||||
...RUN_THEME_FALLBACK.footer,
|
||||
surface,
|
||||
},
|
||||
})
|
||||
await app.renderOnce()
|
||||
|
||||
expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("run entry content updates when live commit text changes", async () => {
|
||||
const [commit, setCommit] = createSignal<StreamCommit>({
|
||||
kind: "tool",
|
||||
|
|
@ -612,7 +639,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||
queuedPrompts={() => [
|
||||
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
|
||||
]}
|
||||
theme={RUN_THEME_FALLBACK}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
backgroundSubagents={true}
|
||||
agent="opencode"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
|
||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
|
||||
import type { StreamCommit } from "@/cli/cmd/run/types"
|
||||
|
||||
type ClaimedCommit = {
|
||||
|
|
@ -62,6 +63,8 @@ async function setup(
|
|||
input: {
|
||||
width?: number
|
||||
wrote?: boolean
|
||||
theme?: RunTheme
|
||||
onThemeRelease?: (theme: RunTheme) => void
|
||||
} = {},
|
||||
) {
|
||||
const out = await createTestRenderer({
|
||||
|
|
@ -78,9 +81,10 @@ async function setup(
|
|||
|
||||
return {
|
||||
renderer: out.renderer,
|
||||
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
|
||||
scrollback: new RunScrollbackStream(out.renderer, input.theme ?? RUN_THEME_FALLBACK, {
|
||||
treeSitterClient,
|
||||
wrote: input.wrote ?? false,
|
||||
onThemeRelease: input.onThemeRelease,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +111,79 @@ function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): Str
|
|||
}
|
||||
}
|
||||
|
||||
test("theme swaps restyle active reasoning without resetting the stream", async () => {
|
||||
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
|
||||
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
|
||||
const released: RunTheme[] = []
|
||||
const previous = {
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
subtleSyntax: previousSyntax,
|
||||
},
|
||||
}
|
||||
const next = {
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
subtleSyntax: nextSyntax,
|
||||
},
|
||||
}
|
||||
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
|
||||
|
||||
try {
|
||||
await out.scrollback.append(reasoning("before"))
|
||||
expect(activeSyntax(out.scrollback)).toBe(previousSyntax)
|
||||
|
||||
out.scrollback.setTheme(next)
|
||||
expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
|
||||
expect(released).toEqual([])
|
||||
|
||||
await out.scrollback.append(reasoning("after"))
|
||||
expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
|
||||
expect(released).toEqual([previous])
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
destroy(claim(out.renderer))
|
||||
previousSyntax.destroy()
|
||||
nextSyntax.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function activeSyntax(scrollback: RunScrollbackStream) {
|
||||
const entry = Reflect.get(scrollback, "active") as { renderable?: { syntaxStyle?: SyntaxStyle } } | undefined
|
||||
return entry?.renderable?.syntaxStyle
|
||||
}
|
||||
|
||||
test("theme swaps preserve streamed markdown parser state", async () => {
|
||||
const out = await setup()
|
||||
const next = {
|
||||
...RUN_THEME_FALLBACK,
|
||||
footer: {
|
||||
...RUN_THEME_FALLBACK.footer,
|
||||
surface: RGBA.fromHex("#123456"),
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await out.scrollback.append(assistant("```ts\nconst answer ="))
|
||||
out.scrollback.setTheme(next)
|
||||
await out.scrollback.append(assistant(" 42\n```"))
|
||||
await out.scrollback.complete()
|
||||
|
||||
const commits = claim(out.renderer)
|
||||
try {
|
||||
const output = render(commits)
|
||||
expect(output).toContain("const answer = 42")
|
||||
expect(output).not.toContain("```")
|
||||
} finally {
|
||||
destroy(commits)
|
||||
}
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function user(text: string): StreamCommit {
|
||||
return {
|
||||
kind: "user",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,43 @@ test("returns syntax styles and indexed splash colors", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("uses refreshed background brightness when cached renderer mode is stale", async () => {
|
||||
const colors = terminalColors({
|
||||
defaultBackground: "#fbf1c7",
|
||||
defaultForeground: "#3c3836",
|
||||
})
|
||||
const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
|
||||
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
|
||||
|
||||
try {
|
||||
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
|
||||
} finally {
|
||||
stale.block.syntax?.destroy()
|
||||
stale.block.subtleSyntax?.destroy()
|
||||
light.block.syntax?.destroy()
|
||||
light.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps renderer mode when refreshed default background is unavailable", async () => {
|
||||
const colors = {
|
||||
...terminalColors(),
|
||||
defaultBackground: null,
|
||||
palette: ["#000000", ...terminalColors().palette.slice(1)],
|
||||
}
|
||||
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
|
||||
const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
|
||||
|
||||
try {
|
||||
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
|
||||
} finally {
|
||||
light.block.syntax?.destroy()
|
||||
light.block.subtleSyntax?.destroy()
|
||||
dark.block.syntax?.destroy()
|
||||
dark.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps dark surfaces neutral on saturated backgrounds", () => {
|
||||
const theme = resolveTheme(
|
||||
generateSystem(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { TerminalColors } from "@opentui/core"
|
||||
|
||||
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } = await import(
|
||||
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme, terminalMode } = await import(
|
||||
"../../../src/cli/cmd/tui/context/theme"
|
||||
)
|
||||
|
||||
|
|
@ -49,3 +50,27 @@ test("resolveTheme rejects circular color refs", () => {
|
|||
|
||||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
|
||||
return {
|
||||
palette,
|
||||
defaultForeground: null,
|
||||
defaultBackground,
|
||||
cursorColor: null,
|
||||
mouseForeground: null,
|
||||
mouseBackground: null,
|
||||
tekForeground: null,
|
||||
tekBackground: null,
|
||||
highlightBackground: null,
|
||||
highlightForeground: null,
|
||||
}
|
||||
}
|
||||
|
||||
test("terminalMode derives mode from refreshed background", () => {
|
||||
expect(terminalMode(terminalColors("#fbf1c7"))).toBe("light")
|
||||
expect(terminalMode(terminalColors("#1a1b26"))).toBe("dark")
|
||||
})
|
||||
|
||||
test("terminalMode does not derive mode from ANSI slot zero", () => {
|
||||
expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue