opencode/run: refresh themes after terminal reloads (#30917)

This commit is contained in:
Simon Klee 2026-06-05 11:47:17 +02:00 committed by GitHub
parent b278e49e96
commit 0c0d193474
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 499 additions and 71 deletions

View File

@ -36,7 +36,7 @@ import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { RunScrollbackStream } from "./scrollback.surface"
import type { RunTheme } from "./theme"
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
import type {
FooterApi,
FooterEvent,
@ -106,6 +106,7 @@ const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
const AUTOCOMPLETE_COMPACT_ROWS = 2
const THEME_REFRESH_DELAYS = [1000, 1000] as const
function createEmptySubagentState(): FooterSubagentState {
return {
@ -191,6 +192,8 @@ export class RunFooter implements FooterApi {
private setVariants: Setter<string[]>
private currentVariant: Accessor<string | undefined>
private setCurrentVariant: Setter<string | undefined>
private theme: Accessor<RunTheme>
private setTheme: Setter<RunTheme>
private state: Accessor<FooterState>
private setState: Setter<FooterState>
private view: Accessor<FooterView>
@ -206,13 +209,23 @@ export class RunFooter implements FooterApi {
private exitTimeout: NodeJS.Timeout | undefined
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
private paletteRefreshRunning = false
private paletteRefreshQueued = false
private themeRefreshTimeouts: NodeJS.Timeout[] = []
private createScrollback(wrote: boolean): RunScrollbackStream {
return new RunScrollbackStream(this.renderer, this.options.theme, {
return new RunScrollbackStream(this.renderer, this.theme(), {
diffStyle: this.options.diffStyle,
wrote,
sessionID: this.options.sessionID,
treeSitterClient: this.options.treeSitterClient,
onThemeRelease: (theme) => {
void this.renderer
.idle()
.catch(() => { })
.finally(() => this.destroyTheme(theme))
},
})
}
@ -257,6 +270,10 @@ export class RunFooter implements FooterApi {
const [currentVariant, setCurrentVariant] = createSignal(options.variant)
this.currentVariant = currentVariant
this.setCurrentVariant = setCurrentVariant
const [theme, setTheme] = createSignal(options.theme)
this.theme = theme
this.setTheme = setTheme
this.themes = [options.theme]
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
this.subagent = () => subagent
this.setSubagent = (next) => {
@ -272,6 +289,10 @@ export class RunFooter implements FooterApi {
this.scrollback = this.createScrollback(options.wrote ?? false)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
this.renderer.prependInputHandler(this.handleThemeNotification)
process.on("SIGUSR2", this.handleThemeSignal)
const footer = this
void render(
@ -293,7 +314,7 @@ export class RunFooter implements FooterApi {
currentModel: footer.currentModel,
variants: footer.variants,
currentVariant: footer.currentVariant,
theme: options.theme,
theme: footer.theme,
diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents,
@ -353,7 +374,7 @@ export class RunFooter implements FooterApi {
public onClose(fn: () => void): () => void {
if (this.isClosed) {
fn()
return () => {}
return () => { }
}
this.closes.add(fn)
@ -548,7 +569,7 @@ export class RunFooter implements FooterApi {
return this.idle()
}
await this.renderer.idle().catch(() => {})
await this.renderer.idle().catch(() => { })
})
}
@ -561,6 +582,21 @@ export class RunFooter implements FooterApi {
this.scrollback = this.createScrollback(wrote)
}
public currentTheme(): RunTheme {
return this.theme()
}
private destroyTheme(theme: RunTheme): void {
const index = this.themes.indexOf(theme)
if (index === -1) {
return
}
this.themes.splice(index, 1)
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
public close(): void {
if (this.closed) {
return
@ -783,7 +819,7 @@ export class RunFooter implements FooterApi {
this.patch(patch)
}
})
.catch(() => {})
.catch(() => { })
}
private handleVariantSelect = (variant: string | undefined): void => {
@ -825,7 +861,7 @@ export class RunFooter implements FooterApi {
this.patch(patch)
}
})
.catch(() => {})
.catch(() => { })
}
private clearInterruptTimer(): void {
@ -922,6 +958,80 @@ export class RunFooter implements FooterApi {
return true
}
private handlePalette = (): void => {
void resolveRunTheme(this.renderer).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
return
}
// Keep the last known good theme when a runtime OSC probe times out.
if (theme === RUN_THEME_FALLBACK) {
return
}
this.themes.push(theme)
this.setTheme(theme)
this.renderer.setBackgroundColor(theme.background)
this.flushing = this.flushing.then(() => this.scrollback.setTheme(theme)).catch((error) => {
this.flushError = error
})
})
}
private handleThemeNotification = (sequence: string): boolean => {
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") {
return false
}
// OpenTUI clears its palette cache only when dark/light mode changes.
// Refresh for same-mode terminal theme swaps too.
queueMicrotask(this.handleThemeRefresh)
return false
}
private handleThemeRefresh = (): void => {
if (this.isGone) {
return
}
if (this.paletteRefreshRunning) {
this.paletteRefreshQueued = true
return
}
this.paletteRefreshRunning = true
const retry = this.renderer.paletteDetectionStatus === "detecting"
this.renderer.clearPaletteCache()
void this.renderer
.getPalette({ size: 256 })
.catch(() => { })
.finally(() => {
this.paletteRefreshRunning = false
if (!retry && !this.paletteRefreshQueued) {
return
}
this.paletteRefreshQueued = false
this.handleThemeRefresh()
})
}
public refreshTheme(): void {
this.handleThemeRefresh()
}
private handleThemeSignal = (): void => {
// Omarchy signals immediately after requesting a terminal config reload.
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
this.themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
setTimeout(() => {
this.handleThemeRefresh()
}, delay),
)
}
private handleDestroy = (): void => {
if (this.destroyed) {
return
@ -933,10 +1043,17 @@ export class RunFooter implements FooterApi {
this.clearInterruptTimer()
this.clearExitTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
this.renderer.removeInputHandler(this.handleThemeNotification)
process.off("SIGUSR2", this.handleThemeSignal)
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
this.themeRefreshTimeouts.length = 0
this.prompts.clear()
this.queuedRemoves.clear()
this.closes.clear()
this.scrollback.destroy()
for (const theme of [...this.themes]) this.destroyTheme(theme)
}
// Drains the commit queue to scrollback. The surface manager owns grouping,

View File

@ -52,7 +52,7 @@ import type {
RunResource,
RunTuiConfig,
} from "./types"
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
import type { RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
const EMPTY_BORDER = {
@ -83,7 +83,7 @@ type RunFooterViewProps = {
view?: () => FooterView
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme?: RunTheme
theme: () => RunTheme
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
@ -237,7 +237,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const duration = createMemo(() => props.state().duration)
const usage = createMemo(() => props.state().usage)
const interruptKey = createMemo(() => interrupt() || "/exit")
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const runTheme = createMemo(() => props.theme())
const theme = createMemo(() => runTheme().footer)
const block = createMemo(() => runTheme().block)
const spin = createMemo(() => {

View File

@ -78,6 +78,7 @@ export type LifecycleInput = {
export type Lifecycle = {
footer: FooterApi
onResize(fn: () => void): () => void
refreshTheme(): void
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
@ -294,7 +295,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
title: splash.title,
session_id: sessionID,
}),
theme: theme.splash,
theme: footer.currentTheme().splash,
}),
)
await renderer.idle().catch(() => {})
@ -313,6 +314,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
return {
footer,
refreshTheme() {
footer.refreshTheme()
},
onResize(fn) {
let width = renderer.terminalWidth
let height = renderer.terminalHeight
@ -347,7 +351,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
title: splash.title,
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
}),
theme: theme.splash,
theme: footer.currentTheme().splash,
showSession: splash.showSession,
}),
)

View File

@ -143,7 +143,7 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
}
const REPLAY_RESIZE_DELAY = 250
const RESIZE_DELAY = 250
const LOCAL_REPLAY_ROW_LIMIT = 100
async function resolveExitTitle(
@ -526,35 +526,38 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
return next
}
let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
const offResize = input.replay
? shell.onResize(() => {
if (replayResizeTimer) {
clearTimeout(replayResizeTimer)
}
let resizeTimer: ReturnType<typeof setTimeout> | undefined
const offResize = shell.onResize(() => {
if (resizeTimer) {
clearTimeout(resizeTimer)
}
replayResizeTimer = setTimeout(() => {
replayResizeTimer = undefined
if (footer.isClosed || !state.stream) {
return
}
resizeTimer = setTimeout(() => {
resizeTimer = undefined
if (footer.isClosed) {
return
}
void state.stream
.then((item) =>
item.handle.replayOnResize({
localRows: () => state.localRows,
reset: () =>
shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
}),
shell.refreshTheme()
if (!input.replay || !state.stream) {
return
}
void state.stream
.then((item) =>
item.handle.replayOnResize({
localRows: () => state.localRows,
reset: () =>
shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
}),
)
.catch(() => {})
}, REPLAY_RESIZE_DELAY)
})
: () => {}
}),
)
.catch(() => {})
}, RESIZE_DELAY)
})
const runQueue = async () => {
let includeFiles = true
@ -759,8 +762,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
try {
await runQueue()
} finally {
if (replayResizeTimer) {
clearTimeout(replayResizeTimer)
if (resizeTimer) {
clearTimeout(resizeTimer)
}
offResize()
await state.stream?.then((item) => item.handle.close()).catch(() => {})

View File

@ -92,6 +92,7 @@ export class RunScrollbackStream {
private sessionID?: () => string | undefined
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private pendingThemes: RunTheme[] = []
constructor(
private renderer: CliRenderer,
@ -101,12 +102,50 @@ export class RunScrollbackStream {
diffStyle?: RunDiffStyle
sessionID?: () => string | undefined
treeSitterClient?: TreeSitterClient
onThemeRelease?: (theme: RunTheme) => void
} = {},
) {
this.diffStyle = options.diffStyle
this.sessionID = options.sessionID
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
this.wrote = options.wrote ?? false
this.onThemeRelease = options.onThemeRelease
}
private onThemeRelease: ((theme: RunTheme) => void) | undefined
private releasePendingThemes(): void {
if (this.pendingThemes.length === 0) {
return
}
for (const theme of this.pendingThemes.splice(0)) this.onThemeRelease?.(theme)
}
public setTheme(theme: RunTheme): void {
if (this.theme === theme) {
return
}
const previous = this.theme
this.theme = theme
const active = this.active
if (!active) {
this.onThemeRelease?.(previous)
return
}
this.pendingThemes.push(previous)
const style = entryLook(active.commit, theme.entry)
if (active.renderable instanceof TextRenderable) {
active.renderable.fg = style.fg
active.renderable.attributes = style.attrs ?? 0
return
}
active.renderable.fg = entryColor(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
}
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@ -203,6 +242,7 @@ export class RunScrollbackStream {
const renderable = active.renderable
renderable.content = active.content
active.surface.render()
this.releasePendingThemes()
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
if (targetRows <= active.committedRows) {
return false
@ -226,6 +266,7 @@ export class RunScrollbackStream {
renderable.content = active.content
renderable.streaming = !done
await active.surface.settle()
this.releasePendingThemes()
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
if (targetRows <= active.committedRows) {
return false
@ -248,6 +289,7 @@ export class RunScrollbackStream {
renderable.content = active.content
renderable.streaming = !done
await active.surface.settle()
this.releasePendingThemes()
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
if (targetBlockCount <= active.committedBlocks) {
return false
@ -288,6 +330,7 @@ export class RunScrollbackStream {
if (!active.surface.isDestroyed) {
active.surface.destroy()
}
this.releasePendingThemes()
}
return active.rendered ? active.commit : undefined
@ -368,6 +411,7 @@ export class RunScrollbackStream {
}
this.active = undefined
this.releasePendingThemes()
}
public async complete(trailingNewline = false): Promise<void> {
@ -386,5 +430,6 @@ export class RunScrollbackStream {
public destroy(): void {
this.resetActive()
this.releasePendingThemes()
}
}

View File

@ -583,7 +583,9 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
return RUN_THEME_FALLBACK
}
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
// Palette-only terminal reloads can leave renderer.themeMode stale, but
// ANSI slot zero is not the terminal background when OSC 11 is absent.
const pick = colors.defaultBackground ? mode(RGBA.fromHex(colors.defaultBackground)) : renderer.themeMode ?? mode(RGBA.fromHex(bg))
const theme = resolveTheme(generateSystem(colors, pick), pick)
const indexed = indexedPalette(colors, 256)
const shared = await import("../tui/context/theme")

View File

@ -50,6 +50,7 @@ type Theme = TuiThemeCurrent & {
}
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
const THEME_REFRESH_DELAYS = [250, 1000] as const
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
@ -332,24 +333,29 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (theme) setStore("active", theme)
})
function init() {
function syncCustomThemes() {
return getCustomThemes()
.then((custom) => {
customThemes = custom
syncThemes()
})
.catch(() => {
setStore("active", "opencode")
})
}
onMount(() => {
void Promise.allSettled([
resolveSystemTheme(store.mode),
getCustomThemes()
.then((custom) => {
customThemes = custom
syncThemes()
})
.catch(() => {
setStore("active", "opencode")
}),
syncCustomThemes(),
]).finally(() => {
setStore("ready", true)
})
}
onMount(init)
})
let systemThemeSignature: string | undefined
let systemThemeMode: "dark" | "light" | undefined
let hasResolvedSystemTheme = false
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
return renderer
.getPalette({
@ -357,6 +363,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
.then((colors: TerminalColors) => {
if (!colors.palette[0]) {
// Keep the last known good generated theme during runtime reloads.
// A terminal config swap can briefly make OSC palette probes fail.
if (hasResolvedSystemTheme) return
systemTheme = undefined
syncThemes()
if (store.active === "system") {
@ -364,10 +373,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
return
}
systemTheme = generateSystem(colors, mode)
const next = store.lock ?? terminalMode(colors) ?? mode
if (store.mode !== next) setStore("mode", next)
const signature = JSON.stringify(colors)
hasResolvedSystemTheme = true
// Delayed reload retries commonly observe the same palette. Avoid
// rebuilding native syntax styles unless the generated theme changed.
if (systemTheme && systemThemeSignature === signature && systemThemeMode === next) return
systemThemeSignature = signature
systemThemeMode = next
systemTheme = generateSystem(colors, next)
syncThemes()
})
.catch(() => {
if (hasResolvedSystemTheme) return
systemTheme = undefined
syncThemes()
if (store.active === "system") {
@ -376,12 +395,33 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
}
let systemRefreshRunning = false
let systemRefreshQueued = false
let systemRefreshMode = store.mode
function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
systemRefreshMode = mode
if (systemRefreshRunning) {
systemRefreshQueued = true
return
}
systemRefreshRunning = true
// clearPaletteCache() does not cancel an older in-flight detection.
const retry = renderer.paletteDetectionStatus === "detecting"
renderer.clearPaletteCache()
void resolveSystemTheme(mode).finally(() => {
systemRefreshRunning = false
if (!retry && !systemRefreshQueued) return
systemRefreshQueued = false
refreshSystemTheme(systemRefreshMode)
})
}
function apply(mode: "dark" | "light") {
if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache()
void resolveSystemTheme(mode)
refreshSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
@ -394,8 +434,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
kv.set("theme_mode", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
refreshSystemTheme(renderer.themeMode ?? store.mode)
}
const handle = (mode: "dark" | "light") => {
@ -404,15 +443,32 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
const handleThemeNotification = (sequence: string) => {
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
queueMicrotask(() => refreshSystemTheme())
return false
}
renderer.prependInputHandler(handleThemeNotification)
let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
const refresh = () => {
renderer.clearPaletteCache()
init()
// Omarchy signals immediately after requesting a terminal config reload.
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
setTimeout(() => {
refreshSystemTheme()
if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
}, delay),
)
}
process.on("SIGUSR2", refresh)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
renderer.removeInputHandler(handleThemeNotification)
process.off("SIGUSR2", refresh)
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
themeRefreshTimeouts.length = 0
})
const values = createMemo(() => {
@ -436,8 +492,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
renderer.setBackgroundColor(values().background)
})
const syntax = createMemo(() => generateSyntax(values()))
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
return {
theme: new Proxy(values(), {
@ -519,6 +575,13 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
const bg = colors.defaultBackground
if (!bg) return
const { r, g, b } = RGBA.fromHex(bg)
return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? "light" : "dark"
}
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
@ -719,6 +782,34 @@ export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
const renderer = useRenderer()
const retained = new Set<SyntaxStyle>()
let current: SyntaxStyle | undefined
const release = (style: SyntaxStyle) => {
retained.add(style)
void renderer
.idle()
.catch(() => { })
.finally(() => {
if (!retained.delete(style)) return
style.destroy()
})
}
onCleanup(() => {
if (current) release(current)
})
return createMemo(() => {
const previous = current
current = factory()
if (previous) release(previous)
return current
})
}
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(

View File

@ -21,7 +21,7 @@ import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
@ -1600,7 +1600,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createMemo(() => generateSubtleSyntax(theme))
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
const toggle = () => {
if (!inMinimal()) return

View File

@ -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"

View File

@ -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",

View File

@ -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(

View File

@ -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()
})