Skip to content

Commit 0c0d193

Browse files
authored
opencode/run: refresh themes after terminal reloads (anomalyco#30917)
1 parent b278e49 commit 0c0d193

12 files changed

Lines changed: 499 additions & 71 deletions

File tree

packages/opencode/src/cli/cmd/run/footer.ts

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
3636
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
3737
import { RunFooterView } from "./footer.view"
3838
import { RunScrollbackStream } from "./scrollback.surface"
39-
import type { RunTheme } from "./theme"
39+
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
4040
import type {
4141
FooterApi,
4242
FooterEvent,
@@ -106,6 +106,7 @@ const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
106106
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
107107
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
108108
const AUTOCOMPLETE_COMPACT_ROWS = 2
109+
const THEME_REFRESH_DELAYS = [1000, 1000] as const
109110

110111
function createEmptySubagentState(): FooterSubagentState {
111112
return {
@@ -191,6 +192,8 @@ export class RunFooter implements FooterApi {
191192
private setVariants: Setter<string[]>
192193
private currentVariant: Accessor<string | undefined>
193194
private setCurrentVariant: Setter<string | undefined>
195+
private theme: Accessor<RunTheme>
196+
private setTheme: Setter<RunTheme>
194197
private state: Accessor<FooterState>
195198
private setState: Setter<FooterState>
196199
private view: Accessor<FooterView>
@@ -206,13 +209,23 @@ export class RunFooter implements FooterApi {
206209
private exitTimeout: NodeJS.Timeout | undefined
207210
private requestExitHandler: (() => boolean) | undefined
208211
private scrollback: RunScrollbackStream
212+
private themes: RunTheme[]
213+
private paletteRefreshRunning = false
214+
private paletteRefreshQueued = false
215+
private themeRefreshTimeouts: NodeJS.Timeout[] = []
209216

210217
private createScrollback(wrote: boolean): RunScrollbackStream {
211-
return new RunScrollbackStream(this.renderer, this.options.theme, {
218+
return new RunScrollbackStream(this.renderer, this.theme(), {
212219
diffStyle: this.options.diffStyle,
213220
wrote,
214221
sessionID: this.options.sessionID,
215222
treeSitterClient: this.options.treeSitterClient,
223+
onThemeRelease: (theme) => {
224+
void this.renderer
225+
.idle()
226+
.catch(() => { })
227+
.finally(() => this.destroyTheme(theme))
228+
},
216229
})
217230
}
218231

@@ -257,6 +270,10 @@ export class RunFooter implements FooterApi {
257270
const [currentVariant, setCurrentVariant] = createSignal(options.variant)
258271
this.currentVariant = currentVariant
259272
this.setCurrentVariant = setCurrentVariant
273+
const [theme, setTheme] = createSignal(options.theme)
274+
this.theme = theme
275+
this.setTheme = setTheme
276+
this.themes = [options.theme]
260277
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
261278
this.subagent = () => subagent
262279
this.setSubagent = (next) => {
@@ -272,6 +289,10 @@ export class RunFooter implements FooterApi {
272289
this.scrollback = this.createScrollback(options.wrote ?? false)
273290

274291
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
292+
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
293+
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
294+
this.renderer.prependInputHandler(this.handleThemeNotification)
295+
process.on("SIGUSR2", this.handleThemeSignal)
275296

276297
const footer = this
277298
void render(
@@ -293,7 +314,7 @@ export class RunFooter implements FooterApi {
293314
currentModel: footer.currentModel,
294315
variants: footer.variants,
295316
currentVariant: footer.currentVariant,
296-
theme: options.theme,
317+
theme: footer.theme,
297318
diffStyle: options.diffStyle,
298319
tuiConfig: options.tuiConfig,
299320
backgroundSubagents: options.backgroundSubagents,
@@ -353,7 +374,7 @@ export class RunFooter implements FooterApi {
353374
public onClose(fn: () => void): () => void {
354375
if (this.isClosed) {
355376
fn()
356-
return () => {}
377+
return () => { }
357378
}
358379

359380
this.closes.add(fn)
@@ -548,7 +569,7 @@ export class RunFooter implements FooterApi {
548569
return this.idle()
549570
}
550571

551-
await this.renderer.idle().catch(() => {})
572+
await this.renderer.idle().catch(() => { })
552573
})
553574
}
554575

@@ -561,6 +582,21 @@ export class RunFooter implements FooterApi {
561582
this.scrollback = this.createScrollback(wrote)
562583
}
563584

585+
public currentTheme(): RunTheme {
586+
return this.theme()
587+
}
588+
589+
private destroyTheme(theme: RunTheme): void {
590+
const index = this.themes.indexOf(theme)
591+
if (index === -1) {
592+
return
593+
}
594+
595+
this.themes.splice(index, 1)
596+
theme.block.syntax?.destroy()
597+
theme.block.subtleSyntax?.destroy()
598+
}
599+
564600
public close(): void {
565601
if (this.closed) {
566602
return
@@ -783,7 +819,7 @@ export class RunFooter implements FooterApi {
783819
this.patch(patch)
784820
}
785821
})
786-
.catch(() => {})
822+
.catch(() => { })
787823
}
788824

789825
private handleVariantSelect = (variant: string | undefined): void => {
@@ -825,7 +861,7 @@ export class RunFooter implements FooterApi {
825861
this.patch(patch)
826862
}
827863
})
828-
.catch(() => {})
864+
.catch(() => { })
829865
}
830866

831867
private clearInterruptTimer(): void {
@@ -922,6 +958,80 @@ export class RunFooter implements FooterApi {
922958
return true
923959
}
924960

961+
private handlePalette = (): void => {
962+
void resolveRunTheme(this.renderer).then((theme) => {
963+
if (this.isGone) {
964+
theme.block.syntax?.destroy()
965+
theme.block.subtleSyntax?.destroy()
966+
return
967+
}
968+
969+
// Keep the last known good theme when a runtime OSC probe times out.
970+
if (theme === RUN_THEME_FALLBACK) {
971+
return
972+
}
973+
974+
this.themes.push(theme)
975+
this.setTheme(theme)
976+
this.renderer.setBackgroundColor(theme.background)
977+
this.flushing = this.flushing.then(() => this.scrollback.setTheme(theme)).catch((error) => {
978+
this.flushError = error
979+
})
980+
})
981+
}
982+
983+
private handleThemeNotification = (sequence: string): boolean => {
984+
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") {
985+
return false
986+
}
987+
988+
// OpenTUI clears its palette cache only when dark/light mode changes.
989+
// Refresh for same-mode terminal theme swaps too.
990+
queueMicrotask(this.handleThemeRefresh)
991+
return false
992+
}
993+
994+
private handleThemeRefresh = (): void => {
995+
if (this.isGone) {
996+
return
997+
}
998+
999+
if (this.paletteRefreshRunning) {
1000+
this.paletteRefreshQueued = true
1001+
return
1002+
}
1003+
1004+
this.paletteRefreshRunning = true
1005+
const retry = this.renderer.paletteDetectionStatus === "detecting"
1006+
this.renderer.clearPaletteCache()
1007+
void this.renderer
1008+
.getPalette({ size: 256 })
1009+
.catch(() => { })
1010+
.finally(() => {
1011+
this.paletteRefreshRunning = false
1012+
if (!retry && !this.paletteRefreshQueued) {
1013+
return
1014+
}
1015+
1016+
this.paletteRefreshQueued = false
1017+
this.handleThemeRefresh()
1018+
})
1019+
}
1020+
1021+
public refreshTheme(): void {
1022+
this.handleThemeRefresh()
1023+
}
1024+
1025+
private handleThemeSignal = (): void => {
1026+
// Omarchy signals immediately after requesting a terminal config reload.
1027+
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
1028+
this.themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
1029+
setTimeout(() => {
1030+
this.handleThemeRefresh()
1031+
}, delay),
1032+
)
1033+
}
1034+
9251035
private handleDestroy = (): void => {
9261036
if (this.destroyed) {
9271037
return
@@ -933,10 +1043,17 @@ export class RunFooter implements FooterApi {
9331043
this.clearInterruptTimer()
9341044
this.clearExitTimer()
9351045
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
1046+
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
1047+
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
1048+
this.renderer.removeInputHandler(this.handleThemeNotification)
1049+
process.off("SIGUSR2", this.handleThemeSignal)
1050+
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
1051+
this.themeRefreshTimeouts.length = 0
9361052
this.prompts.clear()
9371053
this.queuedRemoves.clear()
9381054
this.closes.clear()
9391055
this.scrollback.destroy()
1056+
for (const theme of [...this.themes]) this.destroyTheme(theme)
9401057
}
9411058

9421059
// Drains the commit queue to scrollback. The surface manager owns grouping,

packages/opencode/src/cli/cmd/run/footer.view.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import type {
5252
RunResource,
5353
RunTuiConfig,
5454
} from "./types"
55-
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
55+
import type { RunTheme } from "./theme"
5656
import { modelInfo } from "./variant.shared"
5757

5858
const EMPTY_BORDER = {
@@ -83,7 +83,7 @@ type RunFooterViewProps = {
8383
view?: () => FooterView
8484
subagent?: () => FooterSubagentState
8585
queuedPrompts?: () => FooterQueuedPrompt[]
86-
theme?: RunTheme
86+
theme: () => RunTheme
8787
diffStyle?: RunDiffStyle
8888
tuiConfig: RunTuiConfig
8989
backgroundSubagents: boolean
@@ -237,7 +237,7 @@ export function RunFooterView(props: RunFooterViewProps) {
237237
const duration = createMemo(() => props.state().duration)
238238
const usage = createMemo(() => props.state().usage)
239239
const interruptKey = createMemo(() => interrupt() || "/exit")
240-
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
240+
const runTheme = createMemo(() => props.theme())
241241
const theme = createMemo(() => runTheme().footer)
242242
const block = createMemo(() => runTheme().block)
243243
const spin = createMemo(() => {

packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export type LifecycleInput = {
7878
export type Lifecycle = {
7979
footer: FooterApi
8080
onResize(fn: () => void): () => void
81+
refreshTheme(): void
8182
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
8283
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
8384
}
@@ -294,7 +295,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
294295
title: splash.title,
295296
session_id: sessionID,
296297
}),
297-
theme: theme.splash,
298+
theme: footer.currentTheme().splash,
298299
}),
299300
)
300301
await renderer.idle().catch(() => {})
@@ -313,6 +314,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
313314

314315
return {
315316
footer,
317+
refreshTheme() {
318+
footer.refreshTheme()
319+
},
316320
onResize(fn) {
317321
let width = renderer.terminalWidth
318322
let height = renderer.terminalHeight
@@ -347,7 +351,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
347351
title: splash.title,
348352
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
349353
}),
350-
theme: theme.splash,
354+
theme: footer.currentTheme().splash,
351355
showSession: splash.showSession,
352356
}),
353357
)

packages/opencode/src/cli/cmd/run/runtime.ts

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
143143
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
144144
}
145145

146-
const REPLAY_RESIZE_DELAY = 250
146+
const RESIZE_DELAY = 250
147147
const LOCAL_REPLAY_ROW_LIMIT = 100
148148

149149
async function resolveExitTitle(
@@ -526,35 +526,38 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
526526
return next
527527
}
528528

529-
let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
530-
const offResize = input.replay
531-
? shell.onResize(() => {
532-
if (replayResizeTimer) {
533-
clearTimeout(replayResizeTimer)
534-
}
529+
let resizeTimer: ReturnType<typeof setTimeout> | undefined
530+
const offResize = shell.onResize(() => {
531+
if (resizeTimer) {
532+
clearTimeout(resizeTimer)
533+
}
535534

536-
replayResizeTimer = setTimeout(() => {
537-
replayResizeTimer = undefined
538-
if (footer.isClosed || !state.stream) {
539-
return
540-
}
535+
resizeTimer = setTimeout(() => {
536+
resizeTimer = undefined
537+
if (footer.isClosed) {
538+
return
539+
}
541540

542-
void state.stream
543-
.then((item) =>
544-
item.handle.replayOnResize({
545-
localRows: () => state.localRows,
546-
reset: () =>
547-
shell.resetForReplay({
548-
sessionTitle: state.sessionTitle,
549-
sessionID: state.sessionID,
550-
history: state.history,
551-
}),
541+
shell.refreshTheme()
542+
if (!input.replay || !state.stream) {
543+
return
544+
}
545+
546+
void state.stream
547+
.then((item) =>
548+
item.handle.replayOnResize({
549+
localRows: () => state.localRows,
550+
reset: () =>
551+
shell.resetForReplay({
552+
sessionTitle: state.sessionTitle,
553+
sessionID: state.sessionID,
554+
history: state.history,
552555
}),
553-
)
554-
.catch(() => {})
555-
}, REPLAY_RESIZE_DELAY)
556-
})
557-
: () => {}
556+
}),
557+
)
558+
.catch(() => {})
559+
}, RESIZE_DELAY)
560+
})
558561

559562
const runQueue = async () => {
560563
let includeFiles = true
@@ -759,8 +762,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
759762
try {
760763
await runQueue()
761764
} finally {
762-
if (replayResizeTimer) {
763-
clearTimeout(replayResizeTimer)
765+
if (resizeTimer) {
766+
clearTimeout(resizeTimer)
764767
}
765768
offResize()
766769
await state.stream?.then((item) => item.handle.close()).catch(() => {})

0 commit comments

Comments
 (0)