opencode/run: refresh themes after terminal reloads (#30917)
This commit is contained in:
parent
b278e49e96
commit
0c0d193474
@ -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,
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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(() => {})
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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…
Reference in New Issue
Block a user